Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d1d5f63257 | ||
| fc9b446d2e | |||
|
|
ea68318744 | ||
|
|
518082c2e2 | ||
|
|
056dce0b98 |
@@ -730,12 +730,14 @@ func (s *System) RunStorageAcceptancePack(ctx context.Context, baseDir string, e
|
|||||||
}
|
}
|
||||||
prefix := fmt.Sprintf("%02d-%s", index+1, filepath.Base(devPath))
|
prefix := fmt.Sprintf("%02d-%s", index+1, filepath.Base(devPath))
|
||||||
commands := storageSATCommands(devPath, extended)
|
commands := storageSATCommands(devPath, extended)
|
||||||
|
deviceOutputs := make(map[string][]byte, len(commands))
|
||||||
for cmdIndex, job := range commands {
|
for cmdIndex, job := range commands {
|
||||||
if ctx.Err() != nil {
|
if ctx.Err() != nil {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
name := fmt.Sprintf("%s-%02d-%s.log", prefix, cmdIndex+1, job.name)
|
name := fmt.Sprintf("%s-%02d-%s.log", prefix, cmdIndex+1, job.name)
|
||||||
out, err := runSATCommandCtx(ctx, verboseLog, job.name, job.cmd, nil, logFunc)
|
out, err := runSATCommandCtx(ctx, verboseLog, job.name, job.cmd, nil, logFunc)
|
||||||
|
deviceOutputs[job.name] = out
|
||||||
if writeErr := os.WriteFile(filepath.Join(runDir, name), out, 0644); writeErr != nil {
|
if writeErr := os.WriteFile(filepath.Join(runDir, name), out, 0644); writeErr != nil {
|
||||||
return "", writeErr
|
return "", writeErr
|
||||||
}
|
}
|
||||||
@@ -745,6 +747,8 @@ func (s *System) RunStorageAcceptancePack(ctx context.Context, baseDir string, e
|
|||||||
fmt.Fprintf(&summary, "%s_rc=%d\n", key, rc)
|
fmt.Fprintf(&summary, "%s_rc=%d\n", key, rc)
|
||||||
fmt.Fprintf(&summary, "%s_status=%s\n", key, status)
|
fmt.Fprintf(&summary, "%s_status=%s\n", key, status)
|
||||||
}
|
}
|
||||||
|
reportText := GenerateDiskReportText(index+1, devPath, deviceOutputs, time.Now().UTC())
|
||||||
|
_ = os.WriteFile(filepath.Join(runDir, "disk-"+prefix+"-report.txt"), []byte(reportText), 0644)
|
||||||
}
|
}
|
||||||
|
|
||||||
writeSATStats(&summary, stats)
|
writeSATStats(&summary, stats)
|
||||||
@@ -1185,26 +1189,27 @@ func listStorageDevices() ([]string, error) {
|
|||||||
return parseStorageDevices(string(out)), nil
|
return parseStorageDevices(string(out)), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// storageSATCommands returns the commands to run for a single storage device.
|
||||||
|
// extended=false (Check): read-only SMART/NVMe data collection, no self-test.
|
||||||
|
// extended=true (Load): data collection + short self-test.
|
||||||
func storageSATCommands(devPath string, extended bool) []satJob {
|
func storageSATCommands(devPath string, extended bool) []satJob {
|
||||||
if strings.Contains(filepath.Base(devPath), "nvme") {
|
if strings.Contains(filepath.Base(devPath), "nvme") {
|
||||||
selfTestLevel := "1"
|
jobs := []satJob{
|
||||||
if extended {
|
|
||||||
selfTestLevel = "2"
|
|
||||||
}
|
|
||||||
return []satJob{
|
|
||||||
{name: "nvme-id-ctrl", cmd: []string{"nvme", "id-ctrl", devPath, "-o", "json"}},
|
{name: "nvme-id-ctrl", cmd: []string{"nvme", "id-ctrl", devPath, "-o", "json"}},
|
||||||
{name: "nvme-smart-log", cmd: []string{"nvme", "smart-log", devPath, "-o", "json"}},
|
{name: "nvme-smart-log", cmd: []string{"nvme", "smart-log", devPath, "-o", "json"}},
|
||||||
{name: "nvme-device-self-test", cmd: []string{"nvme", "device-self-test", devPath, "-s", selfTestLevel, "--wait"}},
|
|
||||||
}
|
}
|
||||||
|
if extended {
|
||||||
|
jobs = append(jobs, satJob{name: "nvme-device-self-test", cmd: []string{"nvme", "device-self-test", devPath, "-s", "1", "--wait"}})
|
||||||
|
}
|
||||||
|
return jobs
|
||||||
}
|
}
|
||||||
smartTestType := "short"
|
jobs := []satJob{
|
||||||
if extended {
|
|
||||||
smartTestType = "long"
|
|
||||||
}
|
|
||||||
return []satJob{
|
|
||||||
{name: "smartctl-health", cmd: []string{"smartctl", "-H", "-A", devPath}},
|
{name: "smartctl-health", cmd: []string{"smartctl", "-H", "-A", devPath}},
|
||||||
{name: "smartctl-self-test-short", cmd: []string{"smartctl", "-t", smartTestType, devPath}},
|
|
||||||
}
|
}
|
||||||
|
if extended {
|
||||||
|
jobs = append(jobs, satJob{name: "smartctl-self-test-short", cmd: []string{"smartctl", "-t", "short", devPath}})
|
||||||
|
}
|
||||||
|
return jobs
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *satStats) Add(status string) {
|
func (s *satStats) Add(status string) {
|
||||||
|
|||||||
@@ -14,14 +14,42 @@ import (
|
|||||||
func TestStorageSATCommands(t *testing.T) {
|
func TestStorageSATCommands(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
nvme := storageSATCommands("/dev/nvme0n1", false)
|
// Check mode (extended=false): read-only collection, no self-test.
|
||||||
if len(nvme) != 3 || nvme[2].cmd[0] != "nvme" {
|
nvmeCheck := storageSATCommands("/dev/nvme0n1", false)
|
||||||
t.Fatalf("unexpected nvme commands: %#v", nvme)
|
if len(nvmeCheck) != 2 {
|
||||||
|
t.Fatalf("check nvme: want 2 commands, got %d: %#v", len(nvmeCheck), nvmeCheck)
|
||||||
|
}
|
||||||
|
if nvmeCheck[0].name != "nvme-id-ctrl" || nvmeCheck[1].name != "nvme-smart-log" {
|
||||||
|
t.Fatalf("check nvme: unexpected command names: %#v", nvmeCheck)
|
||||||
}
|
}
|
||||||
|
|
||||||
sata := storageSATCommands("/dev/sda", false)
|
sataCheck := storageSATCommands("/dev/sda", false)
|
||||||
if len(sata) != 2 || sata[0].cmd[0] != "smartctl" {
|
if len(sataCheck) != 1 || sataCheck[0].cmd[0] != "smartctl" {
|
||||||
t.Fatalf("unexpected sata commands: %#v", sata)
|
t.Fatalf("check sata: want 1 smartctl command, got %#v", sataCheck)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load mode (extended=true): collection + short self-test.
|
||||||
|
nvmeLoad := storageSATCommands("/dev/nvme0n1", true)
|
||||||
|
if len(nvmeLoad) != 3 || nvmeLoad[2].name != "nvme-device-self-test" {
|
||||||
|
t.Fatalf("load nvme: want 3 commands with self-test last, got %#v", nvmeLoad)
|
||||||
|
}
|
||||||
|
if got := nvmeLoad[2].cmd[len(nvmeLoad[2].cmd)-3]; got != "-s" {
|
||||||
|
t.Fatalf("load nvme: want -s flag, got %q", got)
|
||||||
|
}
|
||||||
|
if got := nvmeLoad[2].cmd[len(nvmeLoad[2].cmd)-2]; got != "1" {
|
||||||
|
t.Fatalf("load nvme: want self-test level 1, got %q", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
sataLoad := storageSATCommands("/dev/sda", true)
|
||||||
|
if len(sataLoad) != 2 || sataLoad[1].name != "smartctl-self-test-short" {
|
||||||
|
t.Fatalf("load sata: want 2 commands with short self-test last, got %#v", sataLoad)
|
||||||
|
}
|
||||||
|
// cmd is: smartctl -t short /dev/sda
|
||||||
|
if got := sataLoad[1].cmd[1]; got != "-t" {
|
||||||
|
t.Fatalf("load sata: want -t flag at index 1, got %q", got)
|
||||||
|
}
|
||||||
|
if got := sataLoad[1].cmd[2]; got != "short" {
|
||||||
|
t.Fatalf("load sata: want short at index 2, got %q", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
350
audit/internal/platform/storage_report.go
Normal file
350
audit/internal/platform/storage_report.go
Normal file
@@ -0,0 +1,350 @@
|
|||||||
|
package platform
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"math"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// GenerateDiskReportText builds a human-readable text report for one storage
|
||||||
|
// device from the raw command outputs collected during storage SAT.
|
||||||
|
//
|
||||||
|
// outputs keys match satJob.name: "nvme-id-ctrl", "nvme-smart-log",
|
||||||
|
// "smartctl-health", "smartctl-self-test-short".
|
||||||
|
func GenerateDiskReportText(index int, devPath string, outputs map[string][]byte, ts time.Time) string {
|
||||||
|
var b strings.Builder
|
||||||
|
devName := filepath.Base(devPath)
|
||||||
|
line := strings.Repeat("=", 80)
|
||||||
|
b.WriteString(line + "\n")
|
||||||
|
fmt.Fprintf(&b, "Disk %-3d %s\n", index, devPath)
|
||||||
|
b.WriteString(line + "\n")
|
||||||
|
|
||||||
|
isNVMe := strings.Contains(devName, "nvme")
|
||||||
|
if isNVMe {
|
||||||
|
writeNVMeReport(&b, outputs)
|
||||||
|
} else {
|
||||||
|
writeSATAReport(&b, outputs)
|
||||||
|
}
|
||||||
|
|
||||||
|
b.WriteString("\n")
|
||||||
|
fmt.Fprintf(&b, "Collected : %s\n", ts.UTC().Format("2006-01-02 15:04:05 UTC"))
|
||||||
|
b.WriteString(line + "\n")
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── NVMe ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
type nvmeIdCtrl struct {
|
||||||
|
ModelNumber string `json:"mn"`
|
||||||
|
SerialNumber string `json:"sn"`
|
||||||
|
Firmware string `json:"fr"`
|
||||||
|
TotalCap uint64 `json:"tnvmcap"`
|
||||||
|
NVMCap uint64 `json:"nvmcap"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// nvmeU64 handles both plain JSON numbers and {"lo":n,"hi":n} objects that
|
||||||
|
// some nvme-cli versions emit for 128-bit counters.
|
||||||
|
func nvmeU64(raw json.RawMessage) uint64 {
|
||||||
|
if len(raw) == 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
var n uint64
|
||||||
|
if json.Unmarshal(raw, &n) == nil {
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
var obj struct {
|
||||||
|
Lo uint64 `json:"lo"`
|
||||||
|
Hi uint64 `json:"hi"`
|
||||||
|
}
|
||||||
|
if json.Unmarshal(raw, &obj) == nil {
|
||||||
|
return obj.Lo
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
type nvmeSmartLogRaw struct {
|
||||||
|
CriticalWarning uint64 `json:"critical_warning"`
|
||||||
|
Temperature json.RawMessage `json:"temperature"`
|
||||||
|
AvailSpare uint64 `json:"avail_spare"`
|
||||||
|
SpareThresh uint64 `json:"spare_thresh"`
|
||||||
|
PercentUsed uint64 `json:"percent_used"`
|
||||||
|
DataUnitsRead json.RawMessage `json:"data_units_read"`
|
||||||
|
DataUnitsWritten json.RawMessage `json:"data_units_written"`
|
||||||
|
PowerCycles json.RawMessage `json:"power_cycles"`
|
||||||
|
PowerOnHours json.RawMessage `json:"power_on_hours"`
|
||||||
|
UnsafeShutdowns json.RawMessage `json:"unsafe_shutdowns"`
|
||||||
|
MediaErrors json.RawMessage `json:"media_errors"`
|
||||||
|
NumErrLogEntries json.RawMessage `json:"num_err_log_entries"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeNVMeReport(b *strings.Builder, outputs map[string][]byte) {
|
||||||
|
// id-ctrl
|
||||||
|
var ctrl nvmeIdCtrl
|
||||||
|
if data := outputs["nvme-id-ctrl"]; len(data) > 0 {
|
||||||
|
_ = json.Unmarshal(data, &ctrl)
|
||||||
|
}
|
||||||
|
|
||||||
|
model := strings.TrimSpace(ctrl.ModelNumber)
|
||||||
|
serial := strings.TrimSpace(ctrl.SerialNumber)
|
||||||
|
firmware := strings.TrimSpace(ctrl.Firmware)
|
||||||
|
|
||||||
|
capacityGB := ""
|
||||||
|
if ctrl.TotalCap > 0 {
|
||||||
|
capacityGB = formatCapacityGB(ctrl.TotalCap)
|
||||||
|
} else if ctrl.NVMCap > 0 {
|
||||||
|
capacityGB = formatCapacityGB(ctrl.NVMCap)
|
||||||
|
}
|
||||||
|
|
||||||
|
writeField(b, "Model", model)
|
||||||
|
writeField(b, "Serial", serial)
|
||||||
|
writeField(b, "Firmware", firmware)
|
||||||
|
if capacityGB != "" {
|
||||||
|
writeField(b, "Capacity", capacityGB)
|
||||||
|
}
|
||||||
|
|
||||||
|
// smart-log
|
||||||
|
data := outputs["nvme-smart-log"]
|
||||||
|
if len(data) == 0 {
|
||||||
|
b.WriteString("\n(no SMART data)\n")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var sl nvmeSmartLogRaw
|
||||||
|
if err := json.Unmarshal(data, &sl); err != nil {
|
||||||
|
fmt.Fprintf(b, "\n(SMART parse error: %v)\n", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
tempK := nvmeU64(sl.Temperature)
|
||||||
|
tempC := int(tempK) - 273
|
||||||
|
if tempC < 0 {
|
||||||
|
tempC = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
critWarn := sl.CriticalWarning
|
||||||
|
critWarnStr := "OK"
|
||||||
|
if critWarn != 0 {
|
||||||
|
critWarnStr = fmt.Sprintf("0x%02X", critWarn)
|
||||||
|
}
|
||||||
|
|
||||||
|
poh := nvmeU64(sl.PowerOnHours)
|
||||||
|
pc := nvmeU64(sl.PowerCycles)
|
||||||
|
us := nvmeU64(sl.UnsafeShutdowns)
|
||||||
|
me := nvmeU64(sl.MediaErrors)
|
||||||
|
nel := nvmeU64(sl.NumErrLogEntries)
|
||||||
|
|
||||||
|
// data_units are in 1000 × 512-byte sectors = 512,000 bytes each
|
||||||
|
dataRead := float64(nvmeU64(sl.DataUnitsRead)) * 512000 / 1e9
|
||||||
|
dataWritten := float64(nvmeU64(sl.DataUnitsWritten)) * 512000 / 1e9
|
||||||
|
|
||||||
|
writeSectionHeader(b, "Health")
|
||||||
|
writeField(b, "Temperature", fmt.Sprintf("%d °C", tempC))
|
||||||
|
writeField(b, "Critical Warning", critWarnStr)
|
||||||
|
writeField(b, "Percentage Used", fmt.Sprintf("%d %%", sl.PercentUsed))
|
||||||
|
writeField(b, "Available Spare", fmt.Sprintf("%d %% (threshold: %d %%)", sl.AvailSpare, sl.SpareThresh))
|
||||||
|
|
||||||
|
writeSectionHeader(b, "Usage")
|
||||||
|
writeField(b, "Power On Hours", fmt.Sprintf("%s h", formatUint(poh)))
|
||||||
|
writeField(b, "Power Cycles", formatUint(pc))
|
||||||
|
writeField(b, "Unsafe Shutdowns", formatUint(us))
|
||||||
|
writeField(b, "Data Written", fmt.Sprintf("%.1f GB", dataWritten))
|
||||||
|
writeField(b, "Data Read", fmt.Sprintf("%.1f GB", dataRead))
|
||||||
|
|
||||||
|
writeSectionHeader(b, "Errors")
|
||||||
|
writeField(b, "Media Errors", formatUint(me))
|
||||||
|
writeField(b, "Error Log Entries", formatUint(nel))
|
||||||
|
|
||||||
|
if selfTest := outputs["nvme-device-self-test"]; len(selfTest) > 0 {
|
||||||
|
writeSectionHeader(b, "Self-Test")
|
||||||
|
result := parseSelfTestResult(string(selfTest))
|
||||||
|
writeField(b, "Result", result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── SATA / SAS (smartctl) ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
var (
|
||||||
|
smartHealthRE = regexp.MustCompile(`(?i)SMART overall-health self-assessment test result:\s*(\S+)`)
|
||||||
|
smartAttrLineRE = regexp.MustCompile(
|
||||||
|
`^\s*(\d{1,3})\s+(\S+)\s+0x[0-9a-fA-F]+\s+(\d{1,3})\s+(\d{1,3})\s+(\d{1,3})\s+\S+\s+\S+\s+\S+\s+(.+?)\s*$`,
|
||||||
|
)
|
||||||
|
smartModelRE = regexp.MustCompile(`(?im)^Device Model:\s*(.+)$`)
|
||||||
|
smartSerialRE = regexp.MustCompile(`(?im)^Serial Number:\s*(.+)$`)
|
||||||
|
smartFirmwareRE = regexp.MustCompile(`(?im)^Firmware Version:\s*(.+)$`)
|
||||||
|
smartCapacityRE = regexp.MustCompile(`(?im)^User Capacity:\s*(.+)$`)
|
||||||
|
)
|
||||||
|
|
||||||
|
type smartAttr struct {
|
||||||
|
ID int
|
||||||
|
Name string
|
||||||
|
Value int
|
||||||
|
Worst int
|
||||||
|
Threshold int
|
||||||
|
Raw string
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeSATAReport(b *strings.Builder, outputs map[string][]byte) {
|
||||||
|
data := outputs["smartctl-health"]
|
||||||
|
if len(data) == 0 {
|
||||||
|
b.WriteString("\n(no SMART data)\n")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
text := string(data)
|
||||||
|
|
||||||
|
// Identity
|
||||||
|
if m := smartModelRE.FindStringSubmatch(text); m != nil {
|
||||||
|
writeField(b, "Model", strings.TrimSpace(m[1]))
|
||||||
|
}
|
||||||
|
if m := smartSerialRE.FindStringSubmatch(text); m != nil {
|
||||||
|
writeField(b, "Serial", strings.TrimSpace(m[1]))
|
||||||
|
}
|
||||||
|
if m := smartFirmwareRE.FindStringSubmatch(text); m != nil {
|
||||||
|
writeField(b, "Firmware", strings.TrimSpace(m[1]))
|
||||||
|
}
|
||||||
|
if m := smartCapacityRE.FindStringSubmatch(text); m != nil {
|
||||||
|
cap := strings.TrimSpace(m[1])
|
||||||
|
// trim everything after "[" if present (e.g. "500,107,862,016 bytes [500 GB]")
|
||||||
|
if idx := strings.Index(cap, "["); idx > 0 {
|
||||||
|
cap = strings.TrimSpace(cap[idx+1:])
|
||||||
|
cap = strings.TrimSuffix(cap, "]")
|
||||||
|
}
|
||||||
|
writeField(b, "Capacity", cap)
|
||||||
|
}
|
||||||
|
|
||||||
|
writeSectionHeader(b, "Health")
|
||||||
|
health := "unknown"
|
||||||
|
if m := smartHealthRE.FindStringSubmatch(text); m != nil {
|
||||||
|
health = strings.TrimSpace(m[1])
|
||||||
|
}
|
||||||
|
writeField(b, "SMART Overall Health", health)
|
||||||
|
|
||||||
|
attrs := parseSMARTAttrs(text)
|
||||||
|
if len(attrs) > 0 {
|
||||||
|
writeSectionHeader(b, "SMART Attributes")
|
||||||
|
fmt.Fprintf(b, " %-4s %-32s %5s %5s %5s %s\n", "ID", "Attribute", "Value", "Worst", "Thresh", "Raw")
|
||||||
|
b.WriteString(" " + strings.Repeat("-", 72) + "\n")
|
||||||
|
for _, a := range attrs {
|
||||||
|
fmt.Fprintf(b, " %-4d %-32s %5d %5d %5d %s\n",
|
||||||
|
a.ID, a.Name, a.Value, a.Worst, a.Threshold, a.Raw)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if selfTest := outputs["smartctl-self-test-short"]; len(selfTest) > 0 {
|
||||||
|
writeSectionHeader(b, "Self-Test")
|
||||||
|
result := parseSelfTestResult(string(selfTest))
|
||||||
|
writeField(b, "Result", result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseSMARTAttrs(text string) []smartAttr {
|
||||||
|
var attrs []smartAttr
|
||||||
|
inTable := false
|
||||||
|
for _, line := range strings.Split(text, "\n") {
|
||||||
|
if strings.Contains(line, "ATTRIBUTE_NAME") {
|
||||||
|
inTable = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !inTable {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
m := smartAttrLineRE.FindStringSubmatch(line)
|
||||||
|
if m == nil {
|
||||||
|
if strings.TrimSpace(line) == "" {
|
||||||
|
inTable = false
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
id, _ := strconv.Atoi(m[1])
|
||||||
|
val, _ := strconv.Atoi(m[3])
|
||||||
|
worst, _ := strconv.Atoi(m[4])
|
||||||
|
thresh, _ := strconv.Atoi(m[5])
|
||||||
|
attrs = append(attrs, smartAttr{
|
||||||
|
ID: id,
|
||||||
|
Name: m[2],
|
||||||
|
Value: val,
|
||||||
|
Worst: worst,
|
||||||
|
Threshold: thresh,
|
||||||
|
Raw: strings.TrimSpace(m[6]),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return attrs
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseSelfTestResult extracts a one-line summary from nvme device-self-test
|
||||||
|
// or smartctl -t short output.
|
||||||
|
func parseSelfTestResult(text string) string {
|
||||||
|
text = strings.TrimSpace(text)
|
||||||
|
if text == "" {
|
||||||
|
return "no output"
|
||||||
|
}
|
||||||
|
// nvme device-self-test: look for "Short Device Self-Test Status : 0x0" or similar
|
||||||
|
for _, line := range strings.Split(text, "\n") {
|
||||||
|
l := strings.ToLower(line)
|
||||||
|
if strings.Contains(l, "self-test status") || strings.Contains(l, "self test status") {
|
||||||
|
return strings.TrimSpace(line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// smartctl -t short: "Testing has begun" or "Short BGST started"
|
||||||
|
for _, line := range strings.Split(text, "\n") {
|
||||||
|
l := strings.ToLower(line)
|
||||||
|
if strings.Contains(l, "testing has begun") || strings.Contains(l, "started") || strings.Contains(l, "complete") {
|
||||||
|
return strings.TrimSpace(line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// fallback: last non-empty line
|
||||||
|
lines := strings.Split(strings.TrimSpace(text), "\n")
|
||||||
|
for i := len(lines) - 1; i >= 0; i-- {
|
||||||
|
if s := strings.TrimSpace(lines[i]); s != "" {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "done"
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Formatting helpers ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func writeSectionHeader(b *strings.Builder, title string) {
|
||||||
|
b.WriteString("\n")
|
||||||
|
header := "-- " + title + " "
|
||||||
|
header += strings.Repeat("-", max(0, 76-len(header)))
|
||||||
|
b.WriteString(header + "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeField(b *strings.Builder, label, value string) {
|
||||||
|
fmt.Fprintf(b, " %-20s : %s\n", label, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatCapacityGB(bytes uint64) string {
|
||||||
|
gb := float64(bytes) / 1e9
|
||||||
|
if gb >= 1000 {
|
||||||
|
return fmt.Sprintf("%.2g TB", gb/1000)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%.0f GB", math.Round(gb))
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatUint(n uint64) string {
|
||||||
|
if n == 0 {
|
||||||
|
return "0"
|
||||||
|
}
|
||||||
|
s := strconv.FormatUint(n, 10)
|
||||||
|
// insert thousand separators
|
||||||
|
var out []byte
|
||||||
|
for i, c := range s {
|
||||||
|
if i > 0 && (len(s)-i)%3 == 0 {
|
||||||
|
out = append(out, ',')
|
||||||
|
}
|
||||||
|
out = append(out, byte(c))
|
||||||
|
}
|
||||||
|
return string(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
func max(a, b int) int {
|
||||||
|
if a > b {
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
return b
|
||||||
|
}
|
||||||
122
audit/internal/platform/storage_report_test.go
Normal file
122
audit/internal/platform/storage_report_test.go
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
package platform
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
var testNVMeIdCtrl = []byte(`{
|
||||||
|
"mn": "SAMSUNG MZ1L2960HCJR-00A07 ",
|
||||||
|
"sn": "S665NN0X415495",
|
||||||
|
"fr": "GDC7602Q",
|
||||||
|
"tnvmcap": 960197124096
|
||||||
|
}`)
|
||||||
|
|
||||||
|
var testNVMeSmartLog = []byte(`{
|
||||||
|
"critical_warning": 0,
|
||||||
|
"temperature": 311,
|
||||||
|
"avail_spare": 100,
|
||||||
|
"spare_thresh": 10,
|
||||||
|
"percent_used": 0,
|
||||||
|
"data_units_read": 1023456,
|
||||||
|
"data_units_written": 738281,
|
||||||
|
"power_cycles": 32,
|
||||||
|
"power_on_hours": 1234,
|
||||||
|
"unsafe_shutdowns": 3,
|
||||||
|
"media_errors": 0,
|
||||||
|
"num_err_log_entries": 0
|
||||||
|
}`)
|
||||||
|
|
||||||
|
// lo/hi variant emitted by some nvme-cli versions
|
||||||
|
var testNVMeSmartLogLoHi = []byte(`{
|
||||||
|
"critical_warning": 0,
|
||||||
|
"temperature": {"lo": 311, "hi": 0},
|
||||||
|
"avail_spare": 100,
|
||||||
|
"spare_thresh": 10,
|
||||||
|
"percent_used": 0,
|
||||||
|
"data_units_read": {"lo": 1023456, "hi": 0},
|
||||||
|
"data_units_written": {"lo": 738281, "hi": 0},
|
||||||
|
"power_cycles": {"lo": 32, "hi": 0},
|
||||||
|
"power_on_hours": {"lo": 1234, "hi": 0},
|
||||||
|
"unsafe_shutdowns": {"lo": 3, "hi": 0},
|
||||||
|
"media_errors": {"lo": 0, "hi": 0},
|
||||||
|
"num_err_log_entries": {"lo": 0, "hi": 0}
|
||||||
|
}`)
|
||||||
|
|
||||||
|
var testSmartCtlHealth = []byte(`
|
||||||
|
smartctl 7.3 2022-02-28 r5338 [x86_64-linux-5.15.0] (local build)
|
||||||
|
Copyright (C) 2002-22, Bruce Allen, Christian Franke, www.smartmontools.org
|
||||||
|
|
||||||
|
=== START OF INFORMATION SECTION ===
|
||||||
|
Device Model: SAMSUNG MZ1L2960HCJR-00A07
|
||||||
|
Serial Number: S665NN0X415495
|
||||||
|
Firmware Version: GDC7602Q
|
||||||
|
User Capacity: 960,197,124,096 bytes [960 GB]
|
||||||
|
|
||||||
|
=== START OF READ SMART DATA SECTION ===
|
||||||
|
SMART overall-health self-assessment test result: PASSED
|
||||||
|
|
||||||
|
SMART Attributes Data Structure revision number: 1
|
||||||
|
Vendor Specific SMART Attributes with Thresholds:
|
||||||
|
ID# ATTRIBUTE_NAME FLAG VALUE WORST THRESH TYPE UPDATED WHEN_FAILED RAW_VALUE
|
||||||
|
5 Reallocated_Sector_Ct 0x0032 100 100 000 Old_age Always - 0
|
||||||
|
9 Power_On_Hours 0x0032 100 100 000 Old_age Always - 1234
|
||||||
|
12 Power_Cycle_Count 0x0032 100 100 000 Old_age Always - 45
|
||||||
|
177 Wear_Leveling_Count 0x0013 097 097 000 Pre-fail Always - 30
|
||||||
|
190 Airflow_Temperature_Cel 0x0032 063 045 000 Old_age Always - 37
|
||||||
|
`)
|
||||||
|
|
||||||
|
func TestGenerateDiskReportNVMe(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
outputs := map[string][]byte{
|
||||||
|
"nvme-id-ctrl": testNVMeIdCtrl,
|
||||||
|
"nvme-smart-log": testNVMeSmartLog,
|
||||||
|
}
|
||||||
|
report := GenerateDiskReportText(1, "/dev/nvme0n1", outputs, time.Unix(0, 0).UTC())
|
||||||
|
|
||||||
|
assertContains(t, report, "Disk 1", "/dev/nvme0n1")
|
||||||
|
assertContains(t, report, "SAMSUNG MZ1L2960HCJR-00A07")
|
||||||
|
assertContains(t, report, "S665NN0X415495")
|
||||||
|
assertContains(t, report, "GDC7602Q")
|
||||||
|
assertContains(t, report, "38 °C") // 311 K - 273
|
||||||
|
assertContains(t, report, "1,234 h") // power_on_hours with separator
|
||||||
|
assertContains(t, report, "32") // power_cycles
|
||||||
|
assertContains(t, report, "3") // unsafe_shutdowns
|
||||||
|
assertContains(t, report, "378.0 GB") // data_units_written * 512000 / 1e9
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGenerateDiskReportNVMeLoHi(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
outputs := map[string][]byte{
|
||||||
|
"nvme-id-ctrl": testNVMeIdCtrl,
|
||||||
|
"nvme-smart-log": testNVMeSmartLogLoHi,
|
||||||
|
}
|
||||||
|
report := GenerateDiskReportText(1, "/dev/nvme0n1", outputs, time.Unix(0, 0).UTC())
|
||||||
|
assertContains(t, report, "38 °C")
|
||||||
|
assertContains(t, report, "1,234 h")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGenerateDiskReportSATA(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
outputs := map[string][]byte{
|
||||||
|
"smartctl-health": testSmartCtlHealth,
|
||||||
|
}
|
||||||
|
report := GenerateDiskReportText(2, "/dev/sda", outputs, time.Unix(0, 0).UTC())
|
||||||
|
|
||||||
|
assertContains(t, report, "Disk 2", "/dev/sda")
|
||||||
|
assertContains(t, report, "SAMSUNG MZ1L2960HCJR-00A07")
|
||||||
|
assertContains(t, report, "S665NN0X415495")
|
||||||
|
assertContains(t, report, "PASSED")
|
||||||
|
assertContains(t, report, "Reallocated_Sector_Ct")
|
||||||
|
assertContains(t, report, "Power_On_Hours")
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertContains(t *testing.T, text string, needles ...string) {
|
||||||
|
t.Helper()
|
||||||
|
for _, needle := range needles {
|
||||||
|
if !strings.Contains(text, needle) {
|
||||||
|
t.Errorf("report missing %q\nreport:\n%s", needle, text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -413,6 +413,7 @@ func renderFRUEditorCard() string {
|
|||||||
return `<div class="card"><div class="card-head card-head-actions">FRU / Elabel<div class="card-head-buttons"><button class="btn btn-sm btn-secondary" onclick="fruAllRead()">Read All</button></div></div><div class="card-body">
|
return `<div class="card"><div class="card-head card-head-actions">FRU / Elabel<div class="card-head-buttons"><button class="btn btn-sm btn-secondary" onclick="fruAllRead()">Read All</button></div></div><div class="card-body">
|
||||||
<p style="font-size:13px;color:var(--muted);margin-bottom:12px">Reads and edits hardware identity fields from all available sources. Each field shows its source method.</p>
|
<p style="font-size:13px;color:var(--muted);margin-bottom:12px">Reads and edits hardware identity fields from all available sources. Each field shows its source method.</p>
|
||||||
<div id="fru-all-status" style="font-size:13px;color:var(--muted);margin-bottom:8px"></div>
|
<div id="fru-all-status" style="font-size:13px;color:var(--muted);margin-bottom:8px"></div>
|
||||||
|
<div id="fru-src-status" style="display:none;margin-bottom:10px"></div>
|
||||||
<div id="fru-all-table"></div>
|
<div id="fru-all-table"></div>
|
||||||
</div></div>
|
</div></div>
|
||||||
<style>
|
<style>
|
||||||
@@ -480,6 +481,31 @@ var SOURCES = [
|
|||||||
|
|
||||||
function esc(s){return String(s==null?'':s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');}
|
function esc(s){return String(s==null?'':s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');}
|
||||||
|
|
||||||
|
function renderSrcStatus(perSource) {
|
||||||
|
var bar = document.getElementById('fru-src-status');
|
||||||
|
if (!perSource.length) { bar.style.display = 'none'; bar.innerHTML = ''; return; }
|
||||||
|
var html = '';
|
||||||
|
perSource.forEach(function(p) {
|
||||||
|
var state, color;
|
||||||
|
if (p.ok) {
|
||||||
|
state = p.count + ' field(s) available';
|
||||||
|
color = 'var(--ok-fg,green)';
|
||||||
|
} else if (/not activated|product key|SFT-DCMS|SFT-OOB/i.test(p.reason)) {
|
||||||
|
state = 'requires Supermicro license (SFT-OOB-LIC / SFT-DCMS-SINGLE) — activate on BMC';
|
||||||
|
color = 'var(--crit-fg,#9f3a38)';
|
||||||
|
} else {
|
||||||
|
state = p.reason || 'unavailable';
|
||||||
|
color = 'var(--muted)';
|
||||||
|
}
|
||||||
|
html += '<div style="display:flex;align-items:center;gap:8px;font-size:12px;margin:3px 0">'
|
||||||
|
+ '<span class="fru-chip '+p.src.chipClass+'">'+p.src.label+'</span>'
|
||||||
|
+ '<span style="color:'+color+'">'+esc(state)+'</span>'
|
||||||
|
+ '</div>';
|
||||||
|
});
|
||||||
|
bar.innerHTML = html;
|
||||||
|
bar.style.display = '';
|
||||||
|
}
|
||||||
|
|
||||||
window.fruAllRead = function() {
|
window.fruAllRead = function() {
|
||||||
var status = document.getElementById('fru-all-status');
|
var status = document.getElementById('fru-all-status');
|
||||||
var table = document.getElementById('fru-all-table');
|
var table = document.getElementById('fru-all-table');
|
||||||
@@ -494,14 +520,18 @@ window.fruAllRead = function() {
|
|||||||
Promise.allSettled(fetches).then(function(results) {
|
Promise.allSettled(fetches).then(function(results) {
|
||||||
var rows = '';
|
var rows = '';
|
||||||
var totalFields = 0;
|
var totalFields = 0;
|
||||||
var failedSources = [];
|
var perSource = [];
|
||||||
|
|
||||||
results.forEach(function(res, i) {
|
results.forEach(function(res, i) {
|
||||||
var src = SOURCES[i];
|
var src = SOURCES[i];
|
||||||
if (res.status === 'rejected' || !Array.isArray(res.value) || res.value.length === 0) {
|
if (res.status === 'rejected' || !Array.isArray(res.value) || res.value.length === 0) {
|
||||||
failedSources.push(src.label + (res.reason ? ': ' + res.reason.message : ''));
|
var reason = '';
|
||||||
|
if (res.status === 'rejected' && res.reason) reason = res.reason.message;
|
||||||
|
else reason = 'no editable fields returned';
|
||||||
|
perSource.push({src:src, ok:false, count:0, reason:reason});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
perSource.push({src:src, ok:true, count:res.value.length, reason:''});
|
||||||
res.value.forEach(function(f) {
|
res.value.forEach(function(f) {
|
||||||
var val = esc(src.fieldValue(f));
|
var val = esc(src.fieldValue(f));
|
||||||
var ro = src.readOnly(f);
|
var ro = src.readOnly(f);
|
||||||
@@ -526,16 +556,17 @@ window.fruAllRead = function() {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
if (totalFields === 0 && failedSources.length > 0) {
|
renderSrcStatus(perSource);
|
||||||
status.textContent = 'No sources available: ' + failedSources.join('; ');
|
|
||||||
|
if (totalFields === 0) {
|
||||||
|
status.textContent = 'No editable fields available — see per-source status below.';
|
||||||
status.style.color = 'var(--crit-fg,#9f3a38)';
|
status.style.color = 'var(--crit-fg,#9f3a38)';
|
||||||
|
table.innerHTML = '';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
table.innerHTML = '<table style="width:100%;border-collapse:collapse">'+rows+'</table>';
|
table.innerHTML = '<table style="width:100%;border-collapse:collapse">'+rows+'</table>';
|
||||||
var msg = totalFields + ' field(s) loaded';
|
status.textContent = totalFields + ' field(s) loaded';
|
||||||
if (failedSources.length > 0) msg += ' (skipped: ' + failedSources.join(', ') + ')';
|
|
||||||
status.textContent = msg;
|
|
||||||
status.style.color = 'var(--muted)';
|
status.style.color = 'var(--muted)';
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -143,9 +143,9 @@ func renderValidateMode(opts HandlerOptions, stressDefault bool) string {
|
|||||||
)) +
|
)) +
|
||||||
renderSATCard("storage", "Storage", "runSAT('storage')", "", renderValidateCardBody(
|
renderSATCard("storage", "Storage", "runSAT('storage')", "", renderValidateCardBody(
|
||||||
inv.Storage,
|
inv.Storage,
|
||||||
`Scans all storage devices and runs the matching health or self-test path for each device type.`,
|
`Collects SMART data and runs a short self-test on each storage device.`,
|
||||||
`<code>lsblk</code>; NVMe: <code>nvme</code>; SATA/SAS: <code>smartctl</code>`,
|
`<code>lsblk</code>; NVMe: <code>nvme id-ctrl</code>, <code>nvme smart-log</code>, <code>nvme device-self-test -s 1</code>; SATA/SAS: <code>smartctl -H -A</code>, <code>smartctl -t short</code>`,
|
||||||
`Seconds in Validate (NVMe: instant device query; SATA/SAS: short self-test). Up to ~1 h per device in Stress (extended self-test, device-dependent).`,
|
`~2 min per device (NVMe short self-test; SATA/SAS short self-test — duration device-dependent).`,
|
||||||
)) +
|
)) +
|
||||||
`</div>
|
`</div>
|
||||||
<div style="height:1px;background:var(--border);margin:16px 0"></div>
|
<div style="height:1px;background:var(--border);margin:16px 0"></div>
|
||||||
@@ -672,9 +672,9 @@ func renderCheck(opts HandlerOptions) string {
|
|||||||
)) +
|
)) +
|
||||||
renderSATCard("storage", "Storage", "runSAT('storage')", "", renderValidateCardBody(
|
renderSATCard("storage", "Storage", "runSAT('storage')", "", renderValidateCardBody(
|
||||||
inv.Storage,
|
inv.Storage,
|
||||||
`Scans all storage devices and runs the matching health or self-test path for each.`,
|
`Collects SMART health and attributes for each storage device. No self-test is triggered — read-only query only.`,
|
||||||
`<code>lsblk</code>; NVMe: <code>nvme</code>; SATA/SAS: <code>smartctl</code>`,
|
`<code>lsblk</code>; NVMe: <code>nvme id-ctrl</code>, <code>nvme smart-log</code>; SATA/SAS: <code>smartctl -H -A</code>`,
|
||||||
`Seconds (NVMe: instant device query; SATA/SAS: short self-test).`,
|
`Seconds — instantaneous device query, no wear counters incremented.`,
|
||||||
)) +
|
)) +
|
||||||
`</div>
|
`</div>
|
||||||
<div style="height:1px;background:var(--border);margin:16px 0"></div>
|
<div style="height:1px;background:var(--border);margin:16px 0"></div>
|
||||||
|
|||||||
@@ -1227,7 +1227,8 @@ func TestDashboardRendersRuntimeHealthTable(t *testing.T) {
|
|||||||
],
|
],
|
||||||
"services":[
|
"services":[
|
||||||
{"name":"bee-web","status":"active"},
|
{"name":"bee-web","status":"active"},
|
||||||
{"name":"bee-nvidia","status":"inactive"}
|
{"name":"bee-audit","status":"inactive"},
|
||||||
|
{"name":"bee-nvidia","status":"failed"}
|
||||||
]
|
]
|
||||||
}`
|
}`
|
||||||
if err := os.WriteFile(filepath.Join(exportDir, "runtime-health.json"), []byte(health), 0644); err != nil {
|
if err := os.WriteFile(filepath.Join(exportDir, "runtime-health.json"), []byte(health), 0644); err != nil {
|
||||||
@@ -1281,7 +1282,7 @@ func TestDashboardRendersRuntimeHealthTable(t *testing.T) {
|
|||||||
`Bee Services`,
|
`Bee Services`,
|
||||||
`CUDA runtime is not ready for GPU SAT.`,
|
`CUDA runtime is not ready for GPU SAT.`,
|
||||||
`Missing: nvidia-smi`,
|
`Missing: nvidia-smi`,
|
||||||
`bee-nvidia=inactive`,
|
`bee-nvidia=failed`,
|
||||||
// Hardware Summary card — component health badges
|
// Hardware Summary card — component health badges
|
||||||
`Hardware Summary`,
|
`Hardware Summary`,
|
||||||
`>CPU<`,
|
`>CPU<`,
|
||||||
|
|||||||
@@ -232,6 +232,9 @@ func renderTaskReportFragment(report taskReport, charts map[string]string, logTe
|
|||||||
if powerCard := renderTaskPowerResultsCard(report.Target, logText); powerCard != "" {
|
if powerCard := renderTaskPowerResultsCard(report.Target, logText); powerCard != "" {
|
||||||
b.WriteString(powerCard)
|
b.WriteString(powerCard)
|
||||||
}
|
}
|
||||||
|
if report.Target == "storage" {
|
||||||
|
b.WriteString(renderStorageDiskReportCards(logText))
|
||||||
|
}
|
||||||
|
|
||||||
if len(report.Charts) > 0 {
|
if len(report.Charts) > 0 {
|
||||||
for _, chart := range report.Charts {
|
for _, chart := range report.Charts {
|
||||||
@@ -369,3 +372,60 @@ func formatTaskDuration(sec int) string {
|
|||||||
}
|
}
|
||||||
return fmt.Sprintf("%dh %02dm %02ds", sec/3600, (sec%3600)/60, sec%60)
|
return fmt.Sprintf("%dh %02dm %02ds", sec/3600, (sec%3600)/60, sec%60)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// renderStorageDiskReportCards reads disk-*-report.txt files from the storage
|
||||||
|
// SAT run directory and renders one card per disk.
|
||||||
|
func renderStorageDiskReportCards(logText string) string {
|
||||||
|
runDir := taskStorageRunDirFromLog(logText)
|
||||||
|
if runDir == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
entries, err := os.ReadDir(runDir)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
var cards []string
|
||||||
|
for _, entry := range entries {
|
||||||
|
name := entry.Name()
|
||||||
|
if !strings.HasPrefix(name, "disk-") || !strings.HasSuffix(name, "-report.txt") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
data, err := os.ReadFile(filepath.Join(runDir, name))
|
||||||
|
if err != nil || len(data) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Extract disk label from filename: "disk-01-nvme0n1-report.txt" → "Disk 01 — nvme0n1"
|
||||||
|
stem := strings.TrimPrefix(strings.TrimSuffix(name, "-report.txt"), "disk-")
|
||||||
|
// stem is like "01-nvme0n1"
|
||||||
|
parts := strings.SplitN(stem, "-", 2)
|
||||||
|
title := "Disk " + stem
|
||||||
|
if len(parts) == 2 {
|
||||||
|
title = "Disk " + parts[0] + " — " + parts[1]
|
||||||
|
}
|
||||||
|
card := `<div class="card">` +
|
||||||
|
`<div class="card-head">` + html.EscapeString(title) + `</div>` +
|
||||||
|
`<div class="card-body" style="padding:0">` +
|
||||||
|
`<pre style="margin:0;padding:16px;font-size:12px;line-height:1.6;overflow-x:auto;white-space:pre">` +
|
||||||
|
html.EscapeString(string(data)) +
|
||||||
|
`</pre></div></div>`
|
||||||
|
cards = append(cards, card)
|
||||||
|
}
|
||||||
|
return strings.Join(cards, "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
// taskStorageRunDirFromLog finds the storage SAT run directory path logged as
|
||||||
|
// "Archive: /path/to/storage-YYYYMMDD-HHMMSS".
|
||||||
|
func taskStorageRunDirFromLog(logText string) string {
|
||||||
|
for _, line := range strings.Split(logText, "\n") {
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
if !strings.HasPrefix(line, "Archive:") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
path := strings.TrimSpace(strings.TrimPrefix(line, "Archive:"))
|
||||||
|
if strings.Contains(filepath.Base(path), "storage-") && !strings.HasSuffix(path, ".tar.gz") {
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ Generic engineering rules live in `bible/rules/patterns/`.
|
|||||||
| `docs/hardware-ingest-contract.md` | Current Reanimator hardware ingest JSON contract |
|
| `docs/hardware-ingest-contract.md` | Current Reanimator hardware ingest JSON contract |
|
||||||
| `docs/validate-vs-burn.md` | Validate and Validate -> Stress hardware test policy |
|
| `docs/validate-vs-burn.md` | Validate and Validate -> Stress hardware test policy |
|
||||||
| `decisions/` | Architectural decision log, including read-only submodule policy |
|
| `decisions/` | Architectural decision log, including read-only submodule policy |
|
||||||
|
| `proposals/` | RFCs and contract change proposals for Reanimator Core |
|
||||||
|
|
||||||
## Validate Test Matrix
|
## Validate Test Matrix
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,103 @@
|
|||||||
# Backlog
|
# Backlog
|
||||||
|
|
||||||
|
## Сбор SFP-модулей
|
||||||
|
|
||||||
|
**Статус:** не реализовано.
|
||||||
|
|
||||||
|
### Источник данных
|
||||||
|
|
||||||
|
`ethtool -m <iface>` / `ethtool --module-info <iface>` — читает EEPROM SFP/SFP+/QSFP28/QSFP-DD по стандарту MSA (SFF-8472 / SFF-8636).
|
||||||
|
|
||||||
|
Доступные поля из EEPROM:
|
||||||
|
- Идентификатор модуля: `Identifier` (SFP, SFP+, QSFP28, …)
|
||||||
|
- Тип коннектора: `Connector`
|
||||||
|
- Вендор: `Vendor name`, `Vendor OUI`, `Vendor PN`, `Vendor SN`, `Vendor rev`
|
||||||
|
- Оптика: `Wavelength`, `Transceiver type` (10GBase-SR, LR, DAC, …)
|
||||||
|
- Телеметрия DOM (если модуль поддерживает): `Laser tx bias current`, `Transmit avg optical power`, `Receive avg optical power`, `Module temperature`, `Module voltage`
|
||||||
|
- Статус: `Rx power high alarm`, `Tx power low warning`, …
|
||||||
|
|
||||||
|
Для QSFP28 данные повторяются на 4 канала (lane 0–3).
|
||||||
|
|
||||||
|
Инструмент требует root. На bee ISO — доступен (`ethtool` входит в образ).
|
||||||
|
|
||||||
|
### Scope для bee
|
||||||
|
|
||||||
|
1. Собирать список сетевых интерфейсов через `ip -j link show` (только `ether`, без `lo`/VLAN/bond).
|
||||||
|
2. Для каждого интерфейса пробовать `ethtool -m <iface>`. Если модуль отсутствует или не поддерживает EEPROM read — тихо пропускать.
|
||||||
|
3. Связывать интерфейс с PCIe-устройством через `ethtool -i <iface>` → поле `bus-info` (BDF) → сопоставление с `pcie_devices[].slot`.
|
||||||
|
|
||||||
|
### Gap в контракте
|
||||||
|
|
||||||
|
Текущий контракт v2.10 имеет в `pcie_devices[]` скалярные поля:
|
||||||
|
- `sfp_temperature_c`, `sfp_tx_power_dbm`, `sfp_rx_power_dbm`, `sfp_voltage_v`, `sfp_bias_ma`
|
||||||
|
|
||||||
|
Этого **недостаточно**:
|
||||||
|
- Одна NIC-карта может иметь несколько портов — нужен массив, а не скаляр.
|
||||||
|
- Нет полей идентификации модуля (vendor, part_number, serial_number, wavelength, connector).
|
||||||
|
- Нет разбивки по каналам для QSFP28.
|
||||||
|
|
||||||
|
### Предлагаемое расширение контракта
|
||||||
|
|
||||||
|
Добавить в `pcie_devices[]` массив `sfp_modules[]`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
"pcie_devices": [
|
||||||
|
{
|
||||||
|
"slot": "0000:3b:00.0",
|
||||||
|
"device_class": "EthernetController",
|
||||||
|
"model": "ConnectX-6 Dx",
|
||||||
|
"sfp_modules": [
|
||||||
|
{
|
||||||
|
"port": 0,
|
||||||
|
"identifier": "QSFP28",
|
||||||
|
"connector": "LC",
|
||||||
|
"vendor": "Mellanox",
|
||||||
|
"part_number": "MFA1A00-C003",
|
||||||
|
"serial_number": "MT2124VS09999",
|
||||||
|
"revision": "A",
|
||||||
|
"wavelength_nm": 850,
|
||||||
|
"transceiver_type": "100GBase-SR4",
|
||||||
|
"temperature_c": 36.4,
|
||||||
|
"voltage_v": 3.29,
|
||||||
|
"tx_power_dbm": -1.8,
|
||||||
|
"rx_power_dbm": -2.1,
|
||||||
|
"bias_ma": 7.2
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
Поля `sfp_modules[]`:
|
||||||
|
|
||||||
|
| Поле | Тип | Описание |
|
||||||
|
|---|---|---|
|
||||||
|
| `port` | int | Номер порта на NIC (0-based) |
|
||||||
|
| `identifier` | string | `SFP`, `SFP+`, `QSFP28`, `QSFP-DD`, … |
|
||||||
|
| `connector` | string | `LC`, `MPO`, `DAC`, … |
|
||||||
|
| `vendor` | string | Производитель модуля |
|
||||||
|
| `part_number` | string | Партномер |
|
||||||
|
| `serial_number` | string | Серийный номер |
|
||||||
|
| `revision` | string | Ревизия |
|
||||||
|
| `wavelength_nm` | int | Длина волны, нм |
|
||||||
|
| `transceiver_type` | string | `10GBase-SR`, `100GBase-SR4`, `DAC`, … |
|
||||||
|
| `temperature_c` | float | Температура модуля, °C |
|
||||||
|
| `voltage_v` | float | Напряжение, В |
|
||||||
|
| `tx_power_dbm` | float | TX оптическая мощность, dBm |
|
||||||
|
| `rx_power_dbm` | float | RX оптическая мощность, dBm |
|
||||||
|
| `bias_ma` | float | Bias current, мА |
|
||||||
|
|
||||||
|
Старые скалярные поля `sfp_temperature_c` / `sfp_tx_power_dbm` / `sfp_rx_power_dbm` / `sfp_voltage_v` / `sfp_bias_ma` на уровне `pcie_devices[]` — **вывести из контракта** (deprecated), заменить на `sfp_modules[]`.
|
||||||
|
|
||||||
|
### Порядок реализации
|
||||||
|
|
||||||
|
1. Согласовать расширение контракта с Reanimator Core (bump до v2.11).
|
||||||
|
2. Добавить `ethtool` parser в `audit/internal/collector/` — новый файл `sfp.go`.
|
||||||
|
3. Дополнить schema в `audit/internal/schema/` типом `SFPModule`.
|
||||||
|
4. Добавить `sfp_modules` в `PCIeDevice` в schema.
|
||||||
|
5. Заполнять в NIC-коллекторе: связь интерфейс → BDF → `pcie_devices[].sfp_modules`.
|
||||||
|
6. Показывать в TUI и web UI в разделе PCIe/NIC.
|
||||||
|
|
||||||
## BMC версия через IPMI
|
## BMC версия через IPMI
|
||||||
|
|
||||||
**Статус:** реализовано.
|
**Статус:** реализовано.
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
---
|
---
|
||||||
title: Hardware Ingest JSON Contract
|
title: Hardware Ingest JSON Contract
|
||||||
version: "2.10"
|
version: "2.11"
|
||||||
updated: "2026-04-29"
|
updated: "2026-06-19"
|
||||||
maintainer: Reanimator Core
|
maintainer: Reanimator Core
|
||||||
audience: external-integrators, ai-agents
|
audience: external-integrators, ai-agents
|
||||||
language: ru
|
language: ru
|
||||||
@@ -9,7 +9,7 @@ language: ru
|
|||||||
|
|
||||||
# Интеграция с Reanimator: контракт JSON-импорта аппаратного обеспечения
|
# Интеграция с Reanimator: контракт JSON-импорта аппаратного обеспечения
|
||||||
|
|
||||||
Версия: **2.10** · Дата: **2026-04-29**
|
Версия: **2.11** · Дата: **2026-06-19**
|
||||||
|
|
||||||
Документ описывает формат JSON для передачи данных об аппаратном обеспечении серверов в систему **Reanimator** (управление жизненным циклом аппаратного обеспечения).
|
Документ описывает формат JSON для передачи данных об аппаратном обеспечении серверов в систему **Reanimator** (управление жизненным циклом аппаратного обеспечения).
|
||||||
Предназначен для разработчиков смежных систем (Redfish-коллекторов, агентов мониторинга, CMDB-экспортёров) и может быть включён в документацию интегрируемых проектов.
|
Предназначен для разработчиков смежных систем (Redfish-коллекторов, агентов мониторинга, CMDB-экспортёров) и может быть включён в документацию интегрируемых проектов.
|
||||||
@@ -22,6 +22,7 @@ language: ru
|
|||||||
|
|
||||||
| Версия | Дата | Изменения |
|
| Версия | Дата | Изменения |
|
||||||
|--------|------|-----------|
|
|--------|------|-----------|
|
||||||
|
| 2.11 | 2026-06-19 | В `pcie_devices[]` добавлен необязательный массив `sfp_modules[]` с идентификацией и DOM telemetry SFP/QSFP-модулей. Скалярные поля `sfp_temperature_c` / `sfp_tx_power_dbm` / `sfp_rx_power_dbm` / `sfp_voltage_v` / `sfp_bias_ma` помечены как deprecated (принимаются, но `sfp_modules[]` имеет приоритет) |
|
||||||
| 2.10 | 2026-04-29 | Для `hardware.storage[]` добавлены необязательные числовые поля `logical_block_size_bytes`, `physical_block_size_bytes`, `metadata_bytes_per_block` для нормализованного описания формата блока накопителя |
|
| 2.10 | 2026-04-29 | Для `hardware.storage[]` добавлены необязательные числовые поля `logical_block_size_bytes`, `physical_block_size_bytes`, `metadata_bytes_per_block` для нормализованного описания формата блока накопителя |
|
||||||
| 2.9 | 2026-03-19 | Добавлена необязательная секция `hardware.platform_config` — произвольный объект с настройками платформы (BIOS/Redfish); хранится как latest-snapshot per machine |
|
| 2.9 | 2026-03-19 | Добавлена необязательная секция `hardware.platform_config` — произвольный объект с настройками платформы (BIOS/Redfish); хранится как latest-snapshot per machine |
|
||||||
| 2.8 | 2026-03-15 | Поле `location` удалено из всех `sensors.*`; сенсоры передаются только по `name` и измеренным значениям |
|
| 2.8 | 2026-03-15 | Поле `location` удалено из всех `sensors.*`; сенсоры передаются только по `name` и измеренным значениям |
|
||||||
@@ -422,11 +423,12 @@ GET /ingest/hardware/jobs/{job_id}
|
|||||||
| `battery_temperature_c` | float | нет | Температура батареи / supercap, °C |
|
| `battery_temperature_c` | float | нет | Температура батареи / supercap, °C |
|
||||||
| `battery_voltage_v` | float | нет | Напряжение батареи / supercap, В |
|
| `battery_voltage_v` | float | нет | Напряжение батареи / supercap, В |
|
||||||
| `battery_replace_required` | bool | нет | Требуется замена батареи / supercap |
|
| `battery_replace_required` | bool | нет | Требуется замена батареи / supercap |
|
||||||
| `sfp_temperature_c` | float | нет | Температура SFP/optic, °C |
|
| `sfp_temperature_c` | float | нет | Температура SFP/optic, °C *(deprecated since 2.11)* |
|
||||||
| `sfp_tx_power_dbm` | float | нет | TX optical power, dBm |
|
| `sfp_tx_power_dbm` | float | нет | TX optical power, dBm *(deprecated since 2.11)* |
|
||||||
| `sfp_rx_power_dbm` | float | нет | RX optical power, dBm |
|
| `sfp_rx_power_dbm` | float | нет | RX optical power, dBm *(deprecated since 2.11)* |
|
||||||
| `sfp_voltage_v` | float | нет | Напряжение SFP, В |
|
| `sfp_voltage_v` | float | нет | Напряжение SFP, В *(deprecated since 2.11)* |
|
||||||
| `sfp_bias_ma` | float | нет | Bias current SFP, мА |
|
| `sfp_bias_ma` | float | нет | Bias current SFP, мА *(deprecated since 2.11)* |
|
||||||
|
| `sfp_modules` | array | нет | Установленные SFP/QSFP-модули по портам (см. sfp_modules[]) |
|
||||||
| `bdf` | string | нет | Deprecated alias для `slot`; при наличии ingest нормализует его в `slot` |
|
| `bdf` | string | нет | Deprecated alias для `slot`; при наличии ingest нормализует его в `slot` |
|
||||||
| `device_class` | string | нет | Класс устройства (см. список ниже) |
|
| `device_class` | string | нет | Класс устройства (см. список ниже) |
|
||||||
| `manufacturer` | string | нет | Производитель |
|
| `manufacturer` | string | нет | Производитель |
|
||||||
@@ -444,10 +446,43 @@ GET /ingest/hardware/jobs/{job_id}
|
|||||||
`numa_node` передавайте для NIC / InfiniBand / RAID / GPU, когда источник знает CPU/NUMA affinity. Поле сохраняется в snapshot-атрибутах PCIe-компонента и дублируется в telemetry для topology use cases.
|
`numa_node` передавайте для NIC / InfiniBand / RAID / GPU, когда источник знает CPU/NUMA affinity. Поле сохраняется в snapshot-атрибутах PCIe-компонента и дублируется в telemetry для topology use cases.
|
||||||
Поля `temperature_c` и `power_w` используйте для device-level telemetry GPU / accelerator / smart PCIe devices. Они не влияют на идентификацию компонента.
|
Поля `temperature_c` и `power_w` используйте для device-level telemetry GPU / accelerator / smart PCIe devices. Они не влияют на идентификацию компонента.
|
||||||
|
|
||||||
|
**Deprecated поля sfp_\*:** Скалярные поля `sfp_temperature_c`, `sfp_tx_power_dbm`, `sfp_rx_power_dbm`, `sfp_voltage_v`, `sfp_bias_ma` продолжают приниматься, но помечены как deprecated since 2.11. Если в payload одновременно присутствуют `sfp_modules[]` и deprecated sfp_-скаляры — приоритет у `sfp_modules[]`, скаляры игнорируются. Deprecated поля будут удалены в версии 3.0.
|
||||||
|
|
||||||
**Генерация serial_number при отсутствии или `"N/A"`:** `{board_serial}-PCIE-{slot}`, где `slot` для PCIe равен BDF.
|
**Генерация serial_number при отсутствии или `"N/A"`:** `{board_serial}-PCIE-{slot}`, где `slot` для PCIe равен BDF.
|
||||||
|
|
||||||
`slot` — единственный канонический адрес компонента. Для PCIe в `slot` передавайте BDF. Поле `bdf` сохраняется только как переходный alias на входе и не должно использоваться как отдельная координата рядом со `slot`.
|
`slot` — единственный канонический адрес компонента. Для PCIe в `slot` передавайте BDF. Поле `bdf` сохраняется только как переходный alias на входе и не должно использоваться как отдельная координата рядом со `slot`.
|
||||||
|
|
||||||
|
#### pcie_devices[].sfp_modules[]
|
||||||
|
|
||||||
|
Необязательный массив установленных SFP/QSFP-модулей для данного PCIe-устройства. Один элемент — один порт. Используйте для многопортовых NIC (ConnectX-6 Dx, Intel X710, Mellanox HDR и др.).
|
||||||
|
|
||||||
|
| Поле | Тип | Обязательно | Описание |
|
||||||
|
|------|-----|-------------|----------|
|
||||||
|
| `port` | int | **да** | Номер порта на NIC (0-based). Ключ дедупликации внутри устройства |
|
||||||
|
| `identifier` | string | нет | Тип модуля: `SFP`, `SFP+`, `SFP28`, `QSFP+`, `QSFP28`, `QSFP-DD`, `DAC` |
|
||||||
|
| `connector` | string | нет | Тип разъёма: `LC`, `MPO`, `RJ45`, `DAC`, `AOC`, `No separable connector` |
|
||||||
|
| `vendor` | string | нет | Производитель модуля из EEPROM |
|
||||||
|
| `part_number` | string | нет | Партномер из EEPROM |
|
||||||
|
| `serial_number` | string | нет | Серийный номер из EEPROM |
|
||||||
|
| `revision` | string | нет | Ревизия из EEPROM |
|
||||||
|
| `wavelength_nm` | int | нет | Длина волны, нм (0 для DAC/медных кабелей) |
|
||||||
|
| `transceiver_type` | string | нет | `10GBase-SR`, `10GBase-LR`, `25GBase-SR`, `100GBase-SR4`, `DAC`, … |
|
||||||
|
| `temperature_c` | float | нет | Температура модуля, °C (DOM telemetry) |
|
||||||
|
| `voltage_v` | float | нет | Напряжение питания, В (DOM telemetry) |
|
||||||
|
| `tx_power_dbm` | float | нет | TX оптическая мощность, dBm (DOM telemetry) |
|
||||||
|
| `rx_power_dbm` | float | нет | RX оптическая мощность, dBm (DOM telemetry) |
|
||||||
|
| `bias_ma` | float | нет | Bias current, мА (DOM telemetry) |
|
||||||
|
|
||||||
|
**Ключ дедупликации:** `(pcie_devices[].slot, sfp_modules[].port)`.
|
||||||
|
|
||||||
|
**Правила ingest:**
|
||||||
|
- При каждом импорте — полная замена `sfp_modules[]` для данного `pcie_devices[].slot` (upsert всего массива целиком).
|
||||||
|
- Если `sfp_modules` отсутствует или `null` — существующие данные SFP не трогать.
|
||||||
|
- Если `sfp_modules: []` (пустой массив) — трактовать как «модули не обнаружены», очистить сохранённые данные.
|
||||||
|
- Дубли по `port` внутри одного `pcie_devices[]` — невалидны, endpoint возвращает `400` с описанием поля.
|
||||||
|
- Модули без `serial_number` допустимы (многие DAC-кабели не имеют SN); сохраняются по ключу `(slot, port)`.
|
||||||
|
- Изменение `serial_number` или `part_number` модуля на порту создаёт событие `COMPONENT_CHANGED` для PCIe-устройства с описанием «SFP module replaced on port N».
|
||||||
|
|
||||||
**Значения `device_class`:**
|
**Значения `device_class`:**
|
||||||
|
|
||||||
| Значение | Назначение |
|
| Значение | Назначение |
|
||||||
@@ -472,16 +507,47 @@ GET /ingest/hardware/jobs/{job_id}
|
|||||||
"numa_node": 0,
|
"numa_node": 0,
|
||||||
"temperature_c": 48.5,
|
"temperature_c": 48.5,
|
||||||
"power_w": 18.2,
|
"power_w": 18.2,
|
||||||
"sfp_temperature_c": 36.2,
|
|
||||||
"sfp_tx_power_dbm": -1.8,
|
|
||||||
"sfp_rx_power_dbm": -2.1,
|
|
||||||
"device_class": "EthernetController",
|
"device_class": "EthernetController",
|
||||||
"manufacturer": "Intel",
|
"manufacturer": "Mellanox",
|
||||||
"model": "X710 10GbE",
|
"model": "ConnectX-6 Dx",
|
||||||
"serial_number": "K65472-003",
|
"serial_number": "MT2012X12345",
|
||||||
"firmware": "9.20 0x8000d4ae",
|
"firmware": "22.35.2010",
|
||||||
"mac_addresses": ["3c:fd:fe:aa:bb:cc", "3c:fd:fe:aa:bb:cd"],
|
"mac_addresses": ["3c:fd:fe:aa:bb:cc", "3c:fd:fe:aa:bb:cd"],
|
||||||
"status": "OK"
|
"status": "OK",
|
||||||
|
"sfp_modules": [
|
||||||
|
{
|
||||||
|
"port": 0,
|
||||||
|
"identifier": "QSFP28",
|
||||||
|
"connector": "LC",
|
||||||
|
"vendor": "Mellanox",
|
||||||
|
"part_number": "MFA1A00-C003",
|
||||||
|
"serial_number": "MT2124VS09999",
|
||||||
|
"revision": "A",
|
||||||
|
"wavelength_nm": 850,
|
||||||
|
"transceiver_type": "100GBase-SR4",
|
||||||
|
"temperature_c": 36.4,
|
||||||
|
"voltage_v": 3.29,
|
||||||
|
"tx_power_dbm": -1.8,
|
||||||
|
"rx_power_dbm": -2.1,
|
||||||
|
"bias_ma": 7.2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"port": 1,
|
||||||
|
"identifier": "QSFP28",
|
||||||
|
"connector": "LC",
|
||||||
|
"vendor": "Mellanox",
|
||||||
|
"part_number": "MFA1A00-C003",
|
||||||
|
"serial_number": "MT2124VS09998",
|
||||||
|
"revision": "A",
|
||||||
|
"wavelength_nm": 850,
|
||||||
|
"transceiver_type": "100GBase-SR4",
|
||||||
|
"temperature_c": 35.9,
|
||||||
|
"voltage_v": 3.28,
|
||||||
|
"tx_power_dbm": -1.9,
|
||||||
|
"rx_power_dbm": -2.3,
|
||||||
|
"bias_ma": 7.1
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
```
|
```
|
||||||
@@ -793,7 +859,24 @@ PSU без `serial_number` игнорируется.
|
|||||||
"model": "X710 10GbE",
|
"model": "X710 10GbE",
|
||||||
"serial_number": "K65472-003",
|
"serial_number": "K65472-003",
|
||||||
"mac_addresses": ["3c:fd:fe:aa:bb:cc", "3c:fd:fe:aa:bb:cd"],
|
"mac_addresses": ["3c:fd:fe:aa:bb:cc", "3c:fd:fe:aa:bb:cd"],
|
||||||
"status": "OK"
|
"status": "OK",
|
||||||
|
"sfp_modules": [
|
||||||
|
{
|
||||||
|
"port": 0,
|
||||||
|
"identifier": "SFP+",
|
||||||
|
"connector": "LC",
|
||||||
|
"vendor": "Intel",
|
||||||
|
"part_number": "FTLX8574D3BCV-IT",
|
||||||
|
"serial_number": "FNS123456789",
|
||||||
|
"wavelength_nm": 850,
|
||||||
|
"transceiver_type": "10GBase-SR",
|
||||||
|
"temperature_c": 34.1,
|
||||||
|
"voltage_v": 3.30,
|
||||||
|
"tx_power_dbm": -2.5,
|
||||||
|
"rx_power_dbm": -3.0,
|
||||||
|
"bias_ma": 6.8
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"power_supplies": [
|
"power_supplies": [
|
||||||
|
|||||||
201
bible-local/proposals/2026-06-19-sfp-modules-contract-rfc.md
Normal file
201
bible-local/proposals/2026-06-19-sfp-modules-contract-rfc.md
Normal file
@@ -0,0 +1,201 @@
|
|||||||
|
# RFC: SFP Module Identity and Telemetry in Hardware Ingest Contract
|
||||||
|
|
||||||
|
**Date:** 2026-06-19
|
||||||
|
**Status:** proposal
|
||||||
|
**Target:** Reanimator Core — ingest schema, validation, and storage
|
||||||
|
**Requested by:** bee (hardware audit LiveCD)
|
||||||
|
**Contract version affected:** 2.10 → 2.11
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Задача для агента
|
||||||
|
|
||||||
|
Внести изменения в контракт ingest и реализовать их в Reanimator Core:
|
||||||
|
|
||||||
|
1. Добавить массив `sfp_modules[]` внутри `pcie_devices[]`.
|
||||||
|
2. Обновить JSON-схему и валидатор ingest endpoint.
|
||||||
|
3. Реализовать хранение/upsert данных SFP-модулей.
|
||||||
|
4. Пометить скалярные поля `sfp_*` на уровне `pcie_devices[]` как deprecated.
|
||||||
|
5. Обновить документ контракта (`hardware-ingest-contract.md`) до версии 2.11.
|
||||||
|
|
||||||
|
Ниже — всё необходимое для принятия решений без дополнительных вопросов.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Контекст: откуда берутся данные
|
||||||
|
|
||||||
|
**Источник в bee:** `ethtool -m <iface>` (читает EEPROM SFP/SFP+/QSFP28/QSFP-DD по стандарту MSA SFF-8472 / SFF-8636).
|
||||||
|
|
||||||
|
**Связь с PCIe:** `ethtool -i <iface>` возвращает `bus-info` = BDF (`0000:3b:00.0`), который совпадает с `pcie_devices[].slot`. Так bee связывает SFP-данные конкретного интерфейса с PCIe-устройством.
|
||||||
|
|
||||||
|
**Один NIC — несколько модулей:** карта ConnectX-6 Dx (2 порта), Intel X710 (4 порта), Mellanox HDR (2 порта). Каждый порт — отдельный `ethtool -m`, отдельный SFP-модуль. Одного скаляра на устройство недостаточно.
|
||||||
|
|
||||||
|
**QSFP28/QSFP-DD:** 4-канальные модули возвращают telemetry отдельно по каждому каналу (lane). В предложенной схеме lane-уровень не включён в первую версию — только агрегированные значения модуля в целом. Расширение до lane-уровня — отдельный RFC если понадобится.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Проблема с текущим контрактом v2.10
|
||||||
|
|
||||||
|
В `pcie_devices[]` есть пять скалярных полей:
|
||||||
|
|
||||||
|
```
|
||||||
|
sfp_temperature_c float
|
||||||
|
sfp_tx_power_dbm float
|
||||||
|
sfp_rx_power_dbm float
|
||||||
|
sfp_voltage_v float
|
||||||
|
sfp_bias_ma float
|
||||||
|
```
|
||||||
|
|
||||||
|
Ограничения:
|
||||||
|
- **Нет идентификации модуля** — vendor, part_number, serial_number, wavelength отсутствуют; модуль нельзя инвентаризировать как самостоятельный компонент.
|
||||||
|
- **Только один набор значений на устройство** — невозможно описать 4-портовый NIC.
|
||||||
|
- **Нет типа модуля** — SFP, QSFP28, DAC-кабель не различаются.
|
||||||
|
- **Нет connector/transceiver_type** — невозможно понять, оптика это или медь.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Предлагаемое изменение схемы
|
||||||
|
|
||||||
|
### Новая структура `sfp_modules[]`
|
||||||
|
|
||||||
|
Добавляется как необязательное поле внутри каждого объекта `pcie_devices[]`.
|
||||||
|
|
||||||
|
```json
|
||||||
|
"pcie_devices": [
|
||||||
|
{
|
||||||
|
"slot": "0000:3b:00.0",
|
||||||
|
"device_class": "EthernetController",
|
||||||
|
"model": "ConnectX-6 Dx",
|
||||||
|
"manufacturer": "Mellanox",
|
||||||
|
"serial_number": "MT2012X12345",
|
||||||
|
"status": "OK",
|
||||||
|
"sfp_modules": [
|
||||||
|
{
|
||||||
|
"port": 0,
|
||||||
|
"identifier": "QSFP28",
|
||||||
|
"connector": "LC",
|
||||||
|
"vendor": "Mellanox",
|
||||||
|
"part_number": "MFA1A00-C003",
|
||||||
|
"serial_number": "MT2124VS09999",
|
||||||
|
"revision": "A",
|
||||||
|
"wavelength_nm": 850,
|
||||||
|
"transceiver_type": "100GBase-SR4",
|
||||||
|
"temperature_c": 36.4,
|
||||||
|
"voltage_v": 3.29,
|
||||||
|
"tx_power_dbm": -1.8,
|
||||||
|
"rx_power_dbm": -2.1,
|
||||||
|
"bias_ma": 7.2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"port": 1,
|
||||||
|
"identifier": "QSFP28",
|
||||||
|
"connector": "LC",
|
||||||
|
"vendor": "Mellanox",
|
||||||
|
"part_number": "MFA1A00-C003",
|
||||||
|
"serial_number": "MT2124VS09998",
|
||||||
|
"revision": "A",
|
||||||
|
"wavelength_nm": 850,
|
||||||
|
"transceiver_type": "100GBase-SR4",
|
||||||
|
"temperature_c": 35.9,
|
||||||
|
"voltage_v": 3.28,
|
||||||
|
"tx_power_dbm": -1.9,
|
||||||
|
"rx_power_dbm": -2.3,
|
||||||
|
"bias_ma": 7.1
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
### Поля `sfp_modules[]`
|
||||||
|
|
||||||
|
| Поле | Тип | Обязательно | Описание |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `port` | int | **да** | Номер порта на NIC (0-based). Ключ дедупликации внутри устройства. |
|
||||||
|
| `identifier` | string | нет | Тип модуля: `SFP`, `SFP+`, `SFP28`, `QSFP+`, `QSFP28`, `QSFP-DD`, `DAC` |
|
||||||
|
| `connector` | string | нет | Тип разъёма: `LC`, `MPO`, `RJ45`, `DAC`, `AOC`, `No separable connector` |
|
||||||
|
| `vendor` | string | нет | Производитель модуля из EEPROM |
|
||||||
|
| `part_number` | string | нет | Партномер из EEPROM |
|
||||||
|
| `serial_number` | string | нет | Серийный номер из EEPROM |
|
||||||
|
| `revision` | string | нет | Ревизия из EEPROM |
|
||||||
|
| `wavelength_nm` | int | нет | Длина волны, нм (0 для DAC/медных кабелей) |
|
||||||
|
| `transceiver_type` | string | нет | `10GBase-SR`, `10GBase-LR`, `25GBase-SR`, `100GBase-SR4`, `DAC`, … |
|
||||||
|
| `temperature_c` | float | нет | Температура модуля, °C (DOM telemetry) |
|
||||||
|
| `voltage_v` | float | нет | Напряжение питания, В (DOM telemetry) |
|
||||||
|
| `tx_power_dbm` | float | нет | TX оптическая мощность, dBm (DOM telemetry) |
|
||||||
|
| `rx_power_dbm` | float | нет | RX оптическая мощность, dBm (DOM telemetry) |
|
||||||
|
| `bias_ma` | float | нет | Bias current, мА (DOM telemetry) |
|
||||||
|
|
||||||
|
**Ключ дедупликации:** `(pcie_device.slot, sfp_modules[].port)`.
|
||||||
|
|
||||||
|
**Модули без серийного номера** — допустимы; многие DAC-кабели не имеют SN. Не игнорировать, сохранять по ключу `(slot, port)`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Deprecated поля
|
||||||
|
|
||||||
|
Следующие поля на уровне `pcie_devices[]` помечаются как **deprecated** начиная с v2.11:
|
||||||
|
|
||||||
|
```
|
||||||
|
sfp_temperature_c
|
||||||
|
sfp_tx_power_dbm
|
||||||
|
sfp_rx_power_dbm
|
||||||
|
sfp_voltage_v
|
||||||
|
sfp_bias_ma
|
||||||
|
```
|
||||||
|
|
||||||
|
**Поведение при получении deprecated полей:**
|
||||||
|
- Продолжать принимать и сохранять (не ломать существующих интеграторов).
|
||||||
|
- Если одновременно присутствуют `sfp_modules[]` и deprecated скаляры — приоритет у `sfp_modules[]`; скаляры игнорируются.
|
||||||
|
- В документации пометить как `deprecated since 2.11, will be removed in 3.0`.
|
||||||
|
|
||||||
|
**Не удалять** deprecated поля из валидации в этом PR — только пометить в документации и changelog.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Правила ingest для `sfp_modules[]`
|
||||||
|
|
||||||
|
- `sfp_modules[]` хранится как snapshot-атрибут PCIe-компонента (аналогично `mac_addresses`).
|
||||||
|
- При каждом импорте — полная замена `sfp_modules[]` для данного `pcie_devices[].slot` (upsert всего массива целиком, не merge по портам).
|
||||||
|
- Если `sfp_modules` отсутствует или `null` — существующие данные SFP не трогать (не затирать).
|
||||||
|
- Если `sfp_modules: []` (пустой массив) — трактовать как «модули не обнаружены», очистить сохранённые данные.
|
||||||
|
- Изменение `serial_number` или `part_number` модуля на порту — создавать событие `COMPONENT_CHANGED` для PCIe-устройства с описанием «SFP module replaced on port N».
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Изменения в документе контракта
|
||||||
|
|
||||||
|
Файл: `bible-local/docs/hardware-ingest-contract.md`
|
||||||
|
|
||||||
|
1. Заголовок версии: `2.10` → `2.11`, дата → `2026-06-19`.
|
||||||
|
2. Добавить в changelog:
|
||||||
|
```
|
||||||
|
| 2.11 | 2026-06-19 | В `pcie_devices[]` добавлен необязательный массив `sfp_modules[]`
|
||||||
|
с идентификацией и DOM telemetry SFP/QSFP-модулей. Скалярные поля
|
||||||
|
sfp_temperature_c / sfp_tx_power_dbm / sfp_rx_power_dbm / sfp_voltage_v /
|
||||||
|
sfp_bias_ma помечены как deprecated (принимаются, но sfp_modules[] имеет приоритет). |
|
||||||
|
```
|
||||||
|
3. В секции `pcie_devices` добавить строку в таблицу полей:
|
||||||
|
```
|
||||||
|
| `sfp_modules` | array | нет | Установленные SFP/QSFP-модули по портам (см. sfp_modules[]) |
|
||||||
|
```
|
||||||
|
4. Добавить подсекцию `#### pcie_devices[].sfp_modules[]` с таблицей полей и примером JSON (из раздела выше).
|
||||||
|
5. Пометить deprecated поля в таблице: добавить суффикс `*(deprecated since 2.11)*`.
|
||||||
|
6. Обновить полный пример JSON — добавить `sfp_modules` к NIC-записи в `pcie_devices`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Что не нужно делать в этом PR
|
||||||
|
|
||||||
|
- Не добавлять lane-level данные QSFP (tx_power_dbm_lane_0 и т.п.) — отдельный RFC.
|
||||||
|
- Не удалять deprecated поля — только пометить.
|
||||||
|
- Не создавать отдельную top-level секцию `network_ports` — данные остаются вложенными в `pcie_devices`.
|
||||||
|
- Не менять логику идентификации PCIe-компонента — `serial_number` SFP-модуля не является ключом для самостоятельного компонента.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Валидация
|
||||||
|
|
||||||
|
Единственное обязательное поле в `sfp_modules[]` — `port` (int, >= 0).
|
||||||
|
Все остальные поля опциональны.
|
||||||
|
Дубли по `port` внутри одного `pcie_devices[]` — невалидны, возвращать `400` с описанием поля.
|
||||||
Reference in New Issue
Block a user