Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ec89616585 | ||
|
|
c0dbbf96ad | ||
|
|
76484b123c | ||
|
|
8901596152 | ||
|
|
7c504e5056 | ||
|
|
333c44f3ba | ||
|
|
3bca821d3e | ||
|
|
3648e37a1e | ||
|
|
d109e08fab | ||
|
|
11d00b9442 | ||
|
|
6defa5ae15 | ||
|
|
c76658ed00 | ||
|
|
2163017a98 |
@@ -34,6 +34,7 @@ func Run(_ runtimeenv.Mode) schema.HardwareIngestRequest {
|
|||||||
}
|
}
|
||||||
snap.CPUs = enrichCPUsWithTelemetry(snap.CPUs, sensorDoc)
|
snap.CPUs = enrichCPUsWithTelemetry(snap.CPUs, sensorDoc)
|
||||||
snap.Memory = enrichMemoryWithTelemetry(snap.Memory, sensorDoc)
|
snap.Memory = enrichMemoryWithTelemetry(snap.Memory, sensorDoc)
|
||||||
|
bestEffortRescanHotplugStorage()
|
||||||
snap.Storage = collectStorage()
|
snap.Storage = collectStorage()
|
||||||
snap.PCIeDevices = collectPCIe()
|
snap.PCIeDevices = collectPCIe()
|
||||||
snap.PCIeDevices = enrichPCIeWithAMD(snap.PCIeDevices)
|
snap.PCIeDevices = enrichPCIeWithAMD(snap.PCIeDevices)
|
||||||
|
|||||||
@@ -4,7 +4,9 @@ import (
|
|||||||
"bee/audit/internal/schema"
|
"bee/audit/internal/schema"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
@@ -140,6 +142,9 @@ func parseLspciDevice(fields map[string]string) schema.HardwarePCIeDevice {
|
|||||||
} else if numaNode, ok := parsePCINumaNode(fields["NUMANode"]); ok {
|
} else if numaNode, ok := parsePCINumaNode(fields["NUMANode"]); ok {
|
||||||
dev.NUMANode = &numaNode
|
dev.NUMANode = &numaNode
|
||||||
}
|
}
|
||||||
|
if group, ok := readPCIIOMMUGroup(bdf); ok {
|
||||||
|
dev.IOMMUGroup = &group
|
||||||
|
}
|
||||||
if width, ok := readPCIIntAttribute(bdf, "current_link_width"); ok {
|
if width, ok := readPCIIntAttribute(bdf, "current_link_width"); ok {
|
||||||
dev.LinkWidth = &width
|
dev.LinkWidth = &width
|
||||||
}
|
}
|
||||||
@@ -179,6 +184,21 @@ func parseLspciDevice(fields map[string]string) schema.HardwarePCIeDevice {
|
|||||||
return dev
|
return dev
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// readPCIIOMMUGroup resolves the IOMMU group number for a BDF via the
|
||||||
|
// iommu_group symlink in sysfs: .../devices/<bdf>/iommu_group -> .../kernel/iommu_groups/<N>
|
||||||
|
func readPCIIOMMUGroup(bdf string) (int, bool) {
|
||||||
|
link := "/sys/bus/pci/devices/" + bdf + "/iommu_group"
|
||||||
|
target, err := os.Readlink(link)
|
||||||
|
if err != nil {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
n, err := strconv.Atoi(filepath.Base(target))
|
||||||
|
if err != nil {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
return n, true
|
||||||
|
}
|
||||||
|
|
||||||
// readPCIIDs reads vendor and device IDs from sysfs for a given BDF.
|
// readPCIIDs reads vendor and device IDs from sysfs for a given BDF.
|
||||||
func readPCIIDs(bdf string) (vendorID, deviceID int) {
|
func readPCIIDs(bdf string) (vendorID, deviceID int) {
|
||||||
base := "/sys/bus/pci/devices/" + bdf
|
base := "/sys/bus/pci/devices/" + bdf
|
||||||
|
|||||||
@@ -4,12 +4,52 @@ import (
|
|||||||
"bee/audit/internal/schema"
|
"bee/audit/internal/schema"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
pciRescanPath = "/sys/bus/pci/rescan"
|
||||||
|
scsiHostScanGlob = "/sys/class/scsi_host/host*/scan"
|
||||||
|
hotplugWriteFile = os.WriteFile
|
||||||
|
hotplugExecCommand = exec.Command
|
||||||
|
hotplugGlob = filepath.Glob
|
||||||
|
nvmeLBAFCompactRE = regexp.MustCompile(`(?im)^\s*lbaf\s+\d+\s*:\s*ms:(\d+)\s+lbads:(\d+).*?\(in use\)\s*$`)
|
||||||
|
nvmeLBAFVerboseRE = regexp.MustCompile(`(?im)^\s*LBA Format\s+\d+\s*:\s*Metadata Size:\s*(\d+)\s+bytes\s*-\s*Data Size:\s*(\d+)\s+bytes.*?\(in use\)\s*$`)
|
||||||
|
sgReadcapBlockRE = regexp.MustCompile(`(?im)logical block length\s*=\s*(\d+)\s+bytes`)
|
||||||
|
sgReadcapProtRE = regexp.MustCompile(`(?im)prot_en\s*=\s*1`)
|
||||||
|
)
|
||||||
|
|
||||||
|
func bestEffortRescanHotplugStorage() {
|
||||||
|
if err := hotplugWriteFile(pciRescanPath, []byte("1\n"), 0644); err != nil {
|
||||||
|
slog.Info("storage: pci rescan skipped", "path", pciRescanPath, "err", err)
|
||||||
|
} else {
|
||||||
|
slog.Info("storage: triggered pci rescan for hotplug discovery")
|
||||||
|
}
|
||||||
|
|
||||||
|
hostPaths, err := hotplugGlob(scsiHostScanGlob)
|
||||||
|
if err != nil {
|
||||||
|
slog.Info("storage: scsi host scan skipped", "pattern", scsiHostScanGlob, "err", err)
|
||||||
|
} else {
|
||||||
|
for _, path := range hostPaths {
|
||||||
|
if err := hotplugWriteFile(path, []byte("- - -\n"), 0644); err != nil {
|
||||||
|
slog.Info("storage: scsi host scan write failed", "path", path, "err", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
slog.Info("storage: triggered scsi host scan", "path", path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
out, err := hotplugExecCommand("udevadm", "settle", "--timeout=10").CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
slog.Info("storage: udev settle after hotplug rescan failed", "err", err, "output", strings.TrimSpace(string(out)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func collectStorage() []schema.HardwareStorage {
|
func collectStorage() []schema.HardwareStorage {
|
||||||
devs := discoverStorageDevices()
|
devs := discoverStorageDevices()
|
||||||
result := make([]schema.HardwareStorage, 0, len(devs))
|
result := make([]schema.HardwareStorage, 0, len(devs))
|
||||||
@@ -35,6 +75,8 @@ type lsblkDevice struct {
|
|||||||
Model string `json:"model"`
|
Model string `json:"model"`
|
||||||
Tran string `json:"tran"`
|
Tran string `json:"tran"`
|
||||||
Hctl string `json:"hctl"`
|
Hctl string `json:"hctl"`
|
||||||
|
LogSec string `json:"log-sec"`
|
||||||
|
PhySec string `json:"phy-sec"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type lsblkRoot struct {
|
type lsblkRoot struct {
|
||||||
@@ -101,7 +143,7 @@ func isVirtualHDiskModel(model string) bool {
|
|||||||
|
|
||||||
func lsblkDevices() []lsblkDevice {
|
func lsblkDevices() []lsblkDevice {
|
||||||
out, err := exec.Command("lsblk", "-J", "-d",
|
out, err := exec.Command("lsblk", "-J", "-d",
|
||||||
"-o", "NAME,TYPE,SIZE,SERIAL,MODEL,TRAN,HCTL").Output()
|
"-o", "NAME,TYPE,SIZE,SERIAL,MODEL,TRAN,HCTL,LOG-SEC,PHY-SEC").Output()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Warn("storage: lsblk failed", "err", err)
|
slog.Warn("storage: lsblk failed", "err", err)
|
||||||
return nil
|
return nil
|
||||||
@@ -208,6 +250,7 @@ func enrichWithSmartctl(dev lsblkDevice) schema.HardwareStorage {
|
|||||||
present := true
|
present := true
|
||||||
s := schema.HardwareStorage{Present: &present}
|
s := schema.HardwareStorage{Present: &present}
|
||||||
s.Telemetry = map[string]any{"linux_device": "/dev/" + dev.Name}
|
s.Telemetry = map[string]any{"linux_device": "/dev/" + dev.Name}
|
||||||
|
applyStorageBlockGeometry(&s, dev)
|
||||||
|
|
||||||
tran := strings.ToLower(dev.Tran)
|
tran := strings.ToLower(dev.Tran)
|
||||||
devPath := "/dev/" + dev.Name
|
devPath := "/dev/" + dev.Name
|
||||||
@@ -250,6 +293,8 @@ func enrichWithSmartctl(dev lsblkDevice) schema.HardwareStorage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var info smartctlInfo
|
var info smartctlInfo
|
||||||
|
var raw map[string]any
|
||||||
|
_ = json.Unmarshal(out, &raw)
|
||||||
if err := json.Unmarshal(out, &info); err == nil {
|
if err := json.Unmarshal(out, &info); err == nil {
|
||||||
if v := cleanDMIValue(info.ModelName); v != "" {
|
if v := cleanDMIValue(info.ModelName); v != "" {
|
||||||
s.Model = &v
|
s.Model = &v
|
||||||
@@ -302,8 +347,11 @@ func enrichWithSmartctl(dev lsblkDevice) schema.HardwareStorage {
|
|||||||
value := float64(attr.Raw.Value)
|
value := float64(attr.Raw.Value)
|
||||||
s.LifeRemainingPct = &value
|
s.LifeRemainingPct = &value
|
||||||
case 241:
|
case 241:
|
||||||
value := attr.Raw.Value
|
value := smartLBAsToBytes(attr.Raw.Value)
|
||||||
s.WrittenBytes = &value
|
s.WrittenBytes = &value
|
||||||
|
case 242:
|
||||||
|
value := smartLBAsToBytes(attr.Raw.Value)
|
||||||
|
s.ReadBytes = &value
|
||||||
case 197:
|
case 197:
|
||||||
pending = attr.Raw.Value
|
pending = attr.Raw.Value
|
||||||
s.CurrentPendingSectors = &pending
|
s.CurrentPendingSectors = &pending
|
||||||
@@ -321,6 +369,8 @@ func enrichWithSmartctl(dev lsblkDevice) schema.HardwareStorage {
|
|||||||
offlineUncorrectable: uncorrectable,
|
offlineUncorrectable: uncorrectable,
|
||||||
lifeRemainingPct: lifeRemaining,
|
lifeRemainingPct: lifeRemaining,
|
||||||
}
|
}
|
||||||
|
applySCSISmartctlTelemetry(&s, raw, &status)
|
||||||
|
applySCSIProtectionBlockGeometry(&s, devPath)
|
||||||
setStorageHealthStatus(&s, status)
|
setStorageHealthStatus(&s, status)
|
||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
@@ -368,6 +418,7 @@ func enrichWithNVMe(dev lsblkDevice) schema.HardwareStorage {
|
|||||||
Interface: &iface,
|
Interface: &iface,
|
||||||
Telemetry: map[string]any{"linux_device": "/dev/" + dev.Name},
|
Telemetry: map[string]any{"linux_device": "/dev/" + dev.Name},
|
||||||
}
|
}
|
||||||
|
applyStorageBlockGeometry(&s, dev)
|
||||||
|
|
||||||
devPath := "/dev/" + dev.Name
|
devPath := "/dev/" + dev.Name
|
||||||
if v := cleanDMIValue(strings.TrimSpace(dev.Model)); v != "" {
|
if v := cleanDMIValue(strings.TrimSpace(dev.Model)); v != "" {
|
||||||
@@ -402,6 +453,7 @@ func enrichWithNVMe(dev lsblkDevice) schema.HardwareStorage {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
applyNVMeBlockGeometry(&s, devPath)
|
||||||
|
|
||||||
// smart-log: wear telemetry
|
// smart-log: wear telemetry
|
||||||
if out, err := exec.Command("nvme", "smart-log", devPath, "-o", "json").Output(); err == nil {
|
if out, err := exec.Command("nvme", "smart-log", devPath, "-o", "json").Output(); err == nil {
|
||||||
@@ -477,6 +529,251 @@ func nvmeDataUnitsToBytes(units int64) int64 {
|
|||||||
return units * 512000
|
return units * 512000
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func smartLBAsToBytes(lbas int64) int64 {
|
||||||
|
if lbas <= 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return lbas * 512
|
||||||
|
}
|
||||||
|
|
||||||
|
func applySCSISmartctlTelemetry(s *schema.HardwareStorage, raw map[string]any, status *storageHealthStatus) {
|
||||||
|
if s == nil || len(raw) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if v, ok := firstInt64(raw,
|
||||||
|
"path:power_on_time.hours",
|
||||||
|
"path:accumulated_power_on_time.hours",
|
||||||
|
"path:power_on_time.hour",
|
||||||
|
"path:accumulated_power_on_time.hour",
|
||||||
|
); ok && v > 0 && s.PowerOnHours == nil {
|
||||||
|
s.PowerOnHours = &v
|
||||||
|
}
|
||||||
|
if v, ok := firstInt64(raw,
|
||||||
|
"path:power_cycle_count",
|
||||||
|
"path:start_stop_cycle_count",
|
||||||
|
"path:accumulated_start_stop_cycles",
|
||||||
|
); ok && v > 0 && s.PowerCycles == nil {
|
||||||
|
s.PowerCycles = &v
|
||||||
|
}
|
||||||
|
if v, ok := firstInt64(raw,
|
||||||
|
"path:scsi_grown_defect_list",
|
||||||
|
"path:grown_defect_list",
|
||||||
|
); ok && v > 0 && s.ReallocatedSectors == nil {
|
||||||
|
s.ReallocatedSectors = &v
|
||||||
|
if status != nil && status.reallocatedSectors == 0 {
|
||||||
|
status.reallocatedSectors = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if v, ok := firstInt64(raw,
|
||||||
|
"path:percentage_used_endurance_indicator",
|
||||||
|
"path:scsi_percentage_used_endurance_indicator",
|
||||||
|
); ok && v > 0 {
|
||||||
|
if s.LifeUsedPct == nil {
|
||||||
|
fv := float64(v)
|
||||||
|
s.LifeUsedPct = &fv
|
||||||
|
}
|
||||||
|
if s.LifeRemainingPct == nil && v <= 100 {
|
||||||
|
remaining := float64(100 - v)
|
||||||
|
s.LifeRemainingPct = &remaining
|
||||||
|
if status != nil && status.lifeRemainingPct == 0 {
|
||||||
|
status.lifeRemainingPct = int64(remaining)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
blockSize, hasBlockSize := firstInt64(raw,
|
||||||
|
"path:logical_block_size",
|
||||||
|
"path:block_size",
|
||||||
|
"path:user_capacity.block_size",
|
||||||
|
)
|
||||||
|
if hasBlockSize && blockSize > 0 {
|
||||||
|
if s.LogicalBlockSizeBytes == nil {
|
||||||
|
s.LogicalBlockSizeBytes = &blockSize
|
||||||
|
}
|
||||||
|
if s.MetadataBytesPerBlock == nil {
|
||||||
|
zero := int64(0)
|
||||||
|
s.MetadataBytesPerBlock = &zero
|
||||||
|
}
|
||||||
|
if s.Telemetry == nil {
|
||||||
|
s.Telemetry = map[string]any{}
|
||||||
|
}
|
||||||
|
s.Telemetry["logical_block_size_bytes"] = *s.LogicalBlockSizeBytes
|
||||||
|
s.Telemetry["metadata_bytes_per_block"] = *s.MetadataBytesPerBlock
|
||||||
|
s.Telemetry["block_format"] = formatBlockFormat(*s.LogicalBlockSizeBytes, *s.MetadataBytesPerBlock)
|
||||||
|
if v, ok := firstInt64(raw,
|
||||||
|
"path:logical_blocks_written",
|
||||||
|
"path:total_lbas_written",
|
||||||
|
); ok && v > 0 && s.WrittenBytes == nil {
|
||||||
|
bytes := v * blockSize
|
||||||
|
s.WrittenBytes = &bytes
|
||||||
|
}
|
||||||
|
if v, ok := firstInt64(raw,
|
||||||
|
"path:logical_blocks_read",
|
||||||
|
"path:total_lbas_read",
|
||||||
|
); ok && v > 0 && s.ReadBytes == nil {
|
||||||
|
bytes := v * blockSize
|
||||||
|
s.ReadBytes = &bytes
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func applyStorageBlockGeometry(s *schema.HardwareStorage, dev lsblkDevice) {
|
||||||
|
if s == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
logical := parseStorageBytes(dev.LogSec)
|
||||||
|
physical := parseStorageBytes(dev.PhySec)
|
||||||
|
if logical <= 0 && physical <= 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if s.Telemetry == nil {
|
||||||
|
s.Telemetry = map[string]any{}
|
||||||
|
}
|
||||||
|
if logical > 0 {
|
||||||
|
s.LogicalBlockSizeBytes = &logical
|
||||||
|
s.Telemetry["logical_block_size_bytes"] = logical
|
||||||
|
if s.MetadataBytesPerBlock == nil {
|
||||||
|
zero := int64(0)
|
||||||
|
s.MetadataBytesPerBlock = &zero
|
||||||
|
s.Telemetry["metadata_bytes_per_block"] = zero
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if physical > 0 {
|
||||||
|
s.PhysicalBlockSizeBytes = &physical
|
||||||
|
s.Telemetry["physical_block_size_bytes"] = physical
|
||||||
|
}
|
||||||
|
if s.LogicalBlockSizeBytes != nil && s.MetadataBytesPerBlock != nil {
|
||||||
|
s.Telemetry["block_format"] = formatBlockFormat(*s.LogicalBlockSizeBytes, *s.MetadataBytesPerBlock)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func applyNVMeBlockGeometry(s *schema.HardwareStorage, devPath string) {
|
||||||
|
if s == nil || strings.TrimSpace(devPath) == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
out, err := exec.Command("nvme", "id-ns", devPath, "-H").CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
dataBytes, metadataBytes, ok := parseNVMeBlockFormat(string(out))
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setStorageBlockGeometry(s, dataBytes, metadataBytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
func applySCSIProtectionBlockGeometry(s *schema.HardwareStorage, devPath string) {
|
||||||
|
if s == nil || strings.TrimSpace(devPath) == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
out, err := exec.Command("sg_readcap", "-l", devPath).CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
dataBytes, metadataBytes, ok := parseSCSIBlockFormat(string(out))
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setStorageBlockGeometry(s, dataBytes, metadataBytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
func setStorageBlockGeometry(s *schema.HardwareStorage, dataBytes, metadataBytes int64) {
|
||||||
|
if s == nil || dataBytes <= 0 || metadataBytes < 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if s.Telemetry == nil {
|
||||||
|
s.Telemetry = map[string]any{}
|
||||||
|
}
|
||||||
|
s.LogicalBlockSizeBytes = &dataBytes
|
||||||
|
s.MetadataBytesPerBlock = &metadataBytes
|
||||||
|
s.Telemetry["logical_block_size_bytes"] = dataBytes
|
||||||
|
s.Telemetry["metadata_bytes_per_block"] = metadataBytes
|
||||||
|
s.Telemetry["block_format"] = formatBlockFormat(dataBytes, metadataBytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatBlockFormat(dataBytes, metadataBytes int64) string {
|
||||||
|
return strconv.FormatInt(dataBytes, 10) + "+" + strconv.FormatInt(metadataBytes, 10)
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseNVMeBlockFormat(raw string) (dataBytes, metadataBytes int64, ok bool) {
|
||||||
|
if m := nvmeLBAFCompactRE.FindStringSubmatch(raw); len(m) == 3 {
|
||||||
|
ms, errMS := strconv.ParseInt(m[1], 10, 64)
|
||||||
|
lbads, errLBADS := strconv.ParseInt(m[2], 10, 64)
|
||||||
|
if errMS == nil && errLBADS == nil && lbads >= 0 && lbads < 63 {
|
||||||
|
return 1 << lbads, ms, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if m := nvmeLBAFVerboseRE.FindStringSubmatch(raw); len(m) == 3 {
|
||||||
|
ms, errMS := strconv.ParseInt(m[1], 10, 64)
|
||||||
|
ds, errDS := strconv.ParseInt(m[2], 10, 64)
|
||||||
|
if errMS == nil && errDS == nil && ds > 0 {
|
||||||
|
return ds, ms, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0, 0, false
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseSCSIBlockFormat(raw string) (dataBytes, metadataBytes int64, ok bool) {
|
||||||
|
m := sgReadcapBlockRE.FindStringSubmatch(raw)
|
||||||
|
if len(m) != 2 {
|
||||||
|
return 0, 0, false
|
||||||
|
}
|
||||||
|
blockBytes, err := strconv.ParseInt(m[1], 10, 64)
|
||||||
|
if err != nil || blockBytes <= 0 {
|
||||||
|
return 0, 0, false
|
||||||
|
}
|
||||||
|
if sgReadcapProtRE.MatchString(raw) {
|
||||||
|
return blockBytes, 8, true
|
||||||
|
}
|
||||||
|
return blockBytes, 0, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func firstInt64(root map[string]any, candidates ...string) (int64, bool) {
|
||||||
|
for _, candidate := range candidates {
|
||||||
|
if !strings.HasPrefix(candidate, "path:") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
path := strings.TrimPrefix(candidate, "path:")
|
||||||
|
if v, ok := nestedInt64(root, strings.Split(path, ".")); ok {
|
||||||
|
return v, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
|
||||||
|
func nestedInt64(root map[string]any, path []string) (int64, bool) {
|
||||||
|
var current any = root
|
||||||
|
for _, key := range path {
|
||||||
|
obj, ok := current.(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
current, ok = obj[key]
|
||||||
|
if !ok {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
switch v := current.(type) {
|
||||||
|
case float64:
|
||||||
|
return int64(v), true
|
||||||
|
case float32:
|
||||||
|
return int64(v), true
|
||||||
|
case int:
|
||||||
|
return int64(v), true
|
||||||
|
case int64:
|
||||||
|
return v, true
|
||||||
|
case int32:
|
||||||
|
return int64(v), true
|
||||||
|
case json.Number:
|
||||||
|
n, err := v.Int64()
|
||||||
|
return n, err == nil
|
||||||
|
case string:
|
||||||
|
n, err := strconv.ParseInt(strings.TrimSpace(v), 10, 64)
|
||||||
|
return n, err == nil
|
||||||
|
default:
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
type storageHealthStatus struct {
|
type storageHealthStatus struct {
|
||||||
hasOverall bool
|
hasOverall bool
|
||||||
overallPassed bool
|
overallPassed bool
|
||||||
|
|||||||
69
audit/internal/collector/storage_block_format_test.go
Normal file
69
audit/internal/collector/storage_block_format_test.go
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
package collector
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestParseNVMeBlockFormatCompact(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
raw := `
|
||||||
|
lbaf 0 : ms:0 lbads:9 rp:0x2 (in use)
|
||||||
|
lbaf 1 : ms:8 lbads:9 rp:0x1
|
||||||
|
`
|
||||||
|
dataBytes, metadataBytes, ok := parseNVMeBlockFormat(raw)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("parseNVMeBlockFormat returned ok=false")
|
||||||
|
}
|
||||||
|
if dataBytes != 512 || metadataBytes != 0 {
|
||||||
|
t.Fatalf("got %d+%d want 512+0", dataBytes, metadataBytes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseNVMeBlockFormatVerbose(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
raw := `
|
||||||
|
LBA Format 0 : Metadata Size: 8 bytes - Data Size: 512 bytes - Relative Performance: 0 Better (in use)
|
||||||
|
LBA Format 1 : Metadata Size: 0 bytes - Data Size: 4096 bytes - Relative Performance: 1 Best
|
||||||
|
`
|
||||||
|
dataBytes, metadataBytes, ok := parseNVMeBlockFormat(raw)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("parseNVMeBlockFormat returned ok=false")
|
||||||
|
}
|
||||||
|
if dataBytes != 512 || metadataBytes != 8 {
|
||||||
|
t.Fatalf("got %d+%d want 512+8", dataBytes, metadataBytes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseSCSIBlockFormatWithProtection(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
raw := `
|
||||||
|
Read Capacity results:
|
||||||
|
Protection: prot_en=1, p_type=1, p_i_exponent=0
|
||||||
|
Logical block length=512 bytes
|
||||||
|
`
|
||||||
|
dataBytes, metadataBytes, ok := parseSCSIBlockFormat(raw)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("parseSCSIBlockFormat returned ok=false")
|
||||||
|
}
|
||||||
|
if dataBytes != 512 || metadataBytes != 8 {
|
||||||
|
t.Fatalf("got %d+%d want 512+8", dataBytes, metadataBytes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseSCSIBlockFormatWithoutProtection(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
raw := `
|
||||||
|
Read Capacity results:
|
||||||
|
Protection: prot_en=0, p_type=0, p_i_exponent=0
|
||||||
|
Logical block length=4096 bytes
|
||||||
|
`
|
||||||
|
dataBytes, metadataBytes, ok := parseSCSIBlockFormat(raw)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("parseSCSIBlockFormat returned ok=false")
|
||||||
|
}
|
||||||
|
if dataBytes != 4096 || metadataBytes != 0 {
|
||||||
|
t.Fatalf("got %d+%d want 4096+0", dataBytes, metadataBytes)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,12 @@
|
|||||||
package collector
|
package collector
|
||||||
|
|
||||||
import "testing"
|
import (
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
func TestMergeStorageDevicePrefersNonEmptyFields(t *testing.T) {
|
func TestMergeStorageDevicePrefersNonEmptyFields(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
@@ -31,3 +37,82 @@ func TestParseStorageBytes(t *testing.T) {
|
|||||||
t.Fatalf("parseStorageBytes invalid=%d want 0", got)
|
t.Fatalf("parseStorageBytes invalid=%d want 0", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestBestEffortRescanHotplugStorage(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tmp := t.TempDir()
|
||||||
|
rescanPath := filepath.Join(tmp, "pci-rescan")
|
||||||
|
scanDir := filepath.Join(tmp, "scsi_host")
|
||||||
|
host0Path := filepath.Join(scanDir, "host0", "scan")
|
||||||
|
host1Path := filepath.Join(scanDir, "host1", "scan")
|
||||||
|
argsPath := filepath.Join(tmp, "udevadm-args")
|
||||||
|
toolPath := filepath.Join(tmp, "udevadm")
|
||||||
|
if err := os.MkdirAll(filepath.Dir(host0Path), 0755); err != nil {
|
||||||
|
t.Fatalf("mkdir host0: %v", err)
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(filepath.Dir(host1Path), 0755); err != nil {
|
||||||
|
t.Fatalf("mkdir host1: %v", err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(host0Path, nil, 0644); err != nil {
|
||||||
|
t.Fatalf("touch host0 scan: %v", err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(host1Path, nil, 0644); err != nil {
|
||||||
|
t.Fatalf("touch host1 scan: %v", err)
|
||||||
|
}
|
||||||
|
script := "#!/bin/sh\nprintf '%s' \"$*\" > \"" + argsPath + "\"\n"
|
||||||
|
if err := os.WriteFile(toolPath, []byte(script), 0755); err != nil {
|
||||||
|
t.Fatalf("write udevadm stub: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
oldPath := os.Getenv("PATH")
|
||||||
|
if err := os.Setenv("PATH", tmp+string(os.PathListSeparator)+oldPath); err != nil {
|
||||||
|
t.Fatalf("set PATH: %v", err)
|
||||||
|
}
|
||||||
|
defer func() { _ = os.Setenv("PATH", oldPath) }()
|
||||||
|
|
||||||
|
oldRescanPath := pciRescanPath
|
||||||
|
oldSCSIGlob := scsiHostScanGlob
|
||||||
|
oldWriteFile := hotplugWriteFile
|
||||||
|
oldExecCommand := hotplugExecCommand
|
||||||
|
oldGlob := hotplugGlob
|
||||||
|
pciRescanPath = rescanPath
|
||||||
|
scsiHostScanGlob = filepath.Join(scanDir, "host*", "scan")
|
||||||
|
hotplugWriteFile = os.WriteFile
|
||||||
|
hotplugExecCommand = exec.Command
|
||||||
|
hotplugGlob = filepath.Glob
|
||||||
|
defer func() {
|
||||||
|
pciRescanPath = oldRescanPath
|
||||||
|
scsiHostScanGlob = oldSCSIGlob
|
||||||
|
hotplugWriteFile = oldWriteFile
|
||||||
|
hotplugExecCommand = oldExecCommand
|
||||||
|
hotplugGlob = oldGlob
|
||||||
|
}()
|
||||||
|
|
||||||
|
bestEffortRescanHotplugStorage()
|
||||||
|
|
||||||
|
raw, err := os.ReadFile(rescanPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read rescan file: %v", err)
|
||||||
|
}
|
||||||
|
if string(raw) != "1\n" {
|
||||||
|
t.Fatalf("rescan payload=%q want %q", string(raw), "1\n")
|
||||||
|
}
|
||||||
|
for _, path := range []string{host0Path, host1Path} {
|
||||||
|
raw, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read scsi scan file %s: %v", path, err)
|
||||||
|
}
|
||||||
|
if string(raw) != "- - -\n" {
|
||||||
|
t.Fatalf("scsi scan payload at %s =%q want %q", path, string(raw), "- - -\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
args, err := os.ReadFile(argsPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read udevadm args: %v", err)
|
||||||
|
}
|
||||||
|
if got := strings.TrimSpace(string(args)); got != "settle --timeout=10" {
|
||||||
|
t.Fatalf("udevadm args=%q want %q", got, "settle --timeout=10")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
101
audit/internal/collector/storage_scsi_test.go
Normal file
101
audit/internal/collector/storage_scsi_test.go
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
package collector
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"bee/audit/internal/schema"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestApplySCSISmartctlTelemetry(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
raw := map[string]any{
|
||||||
|
"power_on_time": map[string]any{
|
||||||
|
"hours": float64(32123),
|
||||||
|
},
|
||||||
|
"accumulated_start_stop_cycles": float64(17),
|
||||||
|
"scsi_grown_defect_list": float64(4),
|
||||||
|
"percentage_used_endurance_indicator": float64(12),
|
||||||
|
"logical_block_size": float64(4096),
|
||||||
|
"logical_blocks_written": float64(1000),
|
||||||
|
"logical_blocks_read": float64(2000),
|
||||||
|
}
|
||||||
|
|
||||||
|
var disk schema.HardwareStorage
|
||||||
|
status := storageHealthStatus{}
|
||||||
|
applySCSISmartctlTelemetry(&disk, raw, &status)
|
||||||
|
|
||||||
|
if disk.PowerOnHours == nil || *disk.PowerOnHours != 32123 {
|
||||||
|
t.Fatalf("power_on_hours=%v want 32123", disk.PowerOnHours)
|
||||||
|
}
|
||||||
|
if disk.PowerCycles == nil || *disk.PowerCycles != 17 {
|
||||||
|
t.Fatalf("power_cycles=%v want 17", disk.PowerCycles)
|
||||||
|
}
|
||||||
|
if disk.ReallocatedSectors == nil || *disk.ReallocatedSectors != 4 {
|
||||||
|
t.Fatalf("reallocated=%v want 4", disk.ReallocatedSectors)
|
||||||
|
}
|
||||||
|
if disk.WrittenBytes == nil || *disk.WrittenBytes != 4096000 {
|
||||||
|
t.Fatalf("written_bytes=%v want 4096000", disk.WrittenBytes)
|
||||||
|
}
|
||||||
|
if disk.ReadBytes == nil || *disk.ReadBytes != 8192000 {
|
||||||
|
t.Fatalf("read_bytes=%v want 8192000", disk.ReadBytes)
|
||||||
|
}
|
||||||
|
if disk.LogicalBlockSizeBytes == nil || *disk.LogicalBlockSizeBytes != 4096 {
|
||||||
|
t.Fatalf("logical_block_size_bytes=%v want 4096", disk.LogicalBlockSizeBytes)
|
||||||
|
}
|
||||||
|
if disk.MetadataBytesPerBlock == nil || *disk.MetadataBytesPerBlock != 0 {
|
||||||
|
t.Fatalf("metadata_bytes_per_block=%v want 0", disk.MetadataBytesPerBlock)
|
||||||
|
}
|
||||||
|
if disk.LifeUsedPct == nil || *disk.LifeUsedPct != 12 {
|
||||||
|
t.Fatalf("life_used_pct=%v want 12", disk.LifeUsedPct)
|
||||||
|
}
|
||||||
|
if disk.LifeRemainingPct == nil || *disk.LifeRemainingPct != 88 {
|
||||||
|
t.Fatalf("life_remaining_pct=%v want 88", disk.LifeRemainingPct)
|
||||||
|
}
|
||||||
|
if status.reallocatedSectors != 4 {
|
||||||
|
t.Fatalf("status.reallocated=%d want 4", status.reallocatedSectors)
|
||||||
|
}
|
||||||
|
if status.lifeRemainingPct != 88 {
|
||||||
|
t.Fatalf("status.life_remaining_pct=%d want 88", status.lifeRemainingPct)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApplySCSISmartctlTelemetryDoesNotOverwriteExistingValues(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
powerOnHours := int64(10)
|
||||||
|
writtenBytes := int64(20)
|
||||||
|
lifeRemaining := 30.0
|
||||||
|
disk := schema.HardwareStorage{
|
||||||
|
PowerOnHours: &powerOnHours,
|
||||||
|
WrittenBytes: &writtenBytes,
|
||||||
|
LifeRemainingPct: &lifeRemaining,
|
||||||
|
}
|
||||||
|
raw := map[string]any{
|
||||||
|
"power_on_time": map[string]any{"hours": float64(999)},
|
||||||
|
"logical_block_size": float64(512),
|
||||||
|
"logical_blocks_written": float64(999),
|
||||||
|
"percentage_used_endurance_indicator": float64(50),
|
||||||
|
}
|
||||||
|
|
||||||
|
applySCSISmartctlTelemetry(&disk, raw, nil)
|
||||||
|
|
||||||
|
if *disk.PowerOnHours != 10 {
|
||||||
|
t.Fatalf("power_on_hours overwritten: got %d want 10", *disk.PowerOnHours)
|
||||||
|
}
|
||||||
|
if *disk.WrittenBytes != 20 {
|
||||||
|
t.Fatalf("written_bytes overwritten: got %d want 20", *disk.WrittenBytes)
|
||||||
|
}
|
||||||
|
if disk.LogicalBlockSizeBytes == nil || *disk.LogicalBlockSizeBytes != 512 {
|
||||||
|
t.Fatalf("logical_block_size_bytes=%v want 512", disk.LogicalBlockSizeBytes)
|
||||||
|
}
|
||||||
|
if disk.MetadataBytesPerBlock == nil || *disk.MetadataBytesPerBlock != 0 {
|
||||||
|
t.Fatalf("metadata_bytes_per_block=%v want 0", disk.MetadataBytesPerBlock)
|
||||||
|
}
|
||||||
|
if *disk.LifeRemainingPct != 30 {
|
||||||
|
t.Fatalf("life_remaining_pct overwritten: got %v want 30", *disk.LifeRemainingPct)
|
||||||
|
}
|
||||||
|
if disk.LifeUsedPct == nil || *disk.LifeUsedPct != 50 {
|
||||||
|
t.Fatalf("life_used_pct=%v want 50", disk.LifeUsedPct)
|
||||||
|
}
|
||||||
|
}
|
||||||
25
audit/internal/collector/storage_telemetry_test.go
Normal file
25
audit/internal/collector/storage_telemetry_test.go
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
package collector
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestSmartLBAsToBytes(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
lbas int64
|
||||||
|
want int64
|
||||||
|
}{
|
||||||
|
{name: "zero", lbas: 0, want: 0},
|
||||||
|
{name: "single lba", lbas: 1, want: 512},
|
||||||
|
{name: "multiple lbas", lbas: 2048, want: 1048576},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
if got := smartLBAsToBytes(tt.lbas); got != tt.want {
|
||||||
|
t.Fatalf("smartLBAsToBytes(%d)=%d want %d", tt.lbas, got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -147,6 +147,9 @@ type HardwareStorage struct {
|
|||||||
Type *string `json:"type,omitempty"`
|
Type *string `json:"type,omitempty"`
|
||||||
Model *string `json:"model,omitempty"`
|
Model *string `json:"model,omitempty"`
|
||||||
SizeGB *int `json:"size_gb,omitempty"`
|
SizeGB *int `json:"size_gb,omitempty"`
|
||||||
|
LogicalBlockSizeBytes *int64 `json:"logical_block_size_bytes,omitempty"`
|
||||||
|
PhysicalBlockSizeBytes *int64 `json:"physical_block_size_bytes,omitempty"`
|
||||||
|
MetadataBytesPerBlock *int64 `json:"metadata_bytes_per_block,omitempty"`
|
||||||
SerialNumber *string `json:"serial_number,omitempty"`
|
SerialNumber *string `json:"serial_number,omitempty"`
|
||||||
Manufacturer *string `json:"manufacturer,omitempty"`
|
Manufacturer *string `json:"manufacturer,omitempty"`
|
||||||
Firmware *string `json:"firmware,omitempty"`
|
Firmware *string `json:"firmware,omitempty"`
|
||||||
@@ -211,6 +214,7 @@ type HardwarePCIeDevice struct {
|
|||||||
Firmware *string `json:"firmware,omitempty"`
|
Firmware *string `json:"firmware,omitempty"`
|
||||||
MacAddresses []string `json:"mac_addresses,omitempty"`
|
MacAddresses []string `json:"mac_addresses,omitempty"`
|
||||||
Present *bool `json:"present,omitempty"`
|
Present *bool `json:"present,omitempty"`
|
||||||
|
IOMMUGroup *int `json:"iommu_group,omitempty"`
|
||||||
Telemetry map[string]any `json:"-"`
|
Telemetry map[string]any `json:"-"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -44,3 +44,57 @@ func TestHardwareSnapshotMarshalsNewContractFields(t *testing.T) {
|
|||||||
t.Fatalf("missing event_logs payload: %s", text)
|
t.Fatalf("missing event_logs payload: %s", text)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestHardwareSnapshotMarshalsStorageTelemetryFields(t *testing.T) {
|
||||||
|
powerOnHours := int64(12450)
|
||||||
|
writtenBytes := int64(9876543210)
|
||||||
|
readBytes := int64(1234567890)
|
||||||
|
lifeRemainingPct := 91.0
|
||||||
|
logicalBlockSizeBytes := int64(512)
|
||||||
|
physicalBlockSizeBytes := int64(4096)
|
||||||
|
metadataBytesPerBlock := int64(8)
|
||||||
|
|
||||||
|
payload := HardwareIngestRequest{
|
||||||
|
CollectedAt: "2026-03-15T15:00:00Z",
|
||||||
|
Hardware: HardwareSnapshot{
|
||||||
|
Board: HardwareBoard{SerialNumber: "SRV-001"},
|
||||||
|
Storage: []HardwareStorage{
|
||||||
|
{
|
||||||
|
SerialNumber: stringPtr("DISK-001"),
|
||||||
|
Model: stringPtr("TestDisk"),
|
||||||
|
LogicalBlockSizeBytes: &logicalBlockSizeBytes,
|
||||||
|
PhysicalBlockSizeBytes: &physicalBlockSizeBytes,
|
||||||
|
MetadataBytesPerBlock: &metadataBytesPerBlock,
|
||||||
|
PowerOnHours: &powerOnHours,
|
||||||
|
WrittenBytes: &writtenBytes,
|
||||||
|
ReadBytes: &readBytes,
|
||||||
|
LifeRemainingPct: &lifeRemainingPct,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := json.Marshal(payload)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal: %v", err)
|
||||||
|
}
|
||||||
|
text := string(data)
|
||||||
|
for _, needle := range []string{
|
||||||
|
`"storage":[{`,
|
||||||
|
`"logical_block_size_bytes":512`,
|
||||||
|
`"physical_block_size_bytes":4096`,
|
||||||
|
`"metadata_bytes_per_block":8`,
|
||||||
|
`"power_on_hours":12450`,
|
||||||
|
`"written_bytes":9876543210`,
|
||||||
|
`"read_bytes":1234567890`,
|
||||||
|
`"life_remaining_pct":91`,
|
||||||
|
} {
|
||||||
|
if !strings.Contains(text, needle) {
|
||||||
|
t.Fatalf("missing %q in payload: %s", needle, text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func stringPtr(v string) *string {
|
||||||
|
return &v
|
||||||
|
}
|
||||||
|
|||||||
@@ -572,6 +572,7 @@ func (h *handler) handleExportIndex(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
func (h *handler) handleViewer(w http.ResponseWriter, r *http.Request) {
|
func (h *handler) handleViewer(w http.ResponseWriter, r *http.Request) {
|
||||||
snapshot, _ := loadSnapshot(h.opts.AuditPath)
|
snapshot, _ := loadSnapshot(h.opts.AuditPath)
|
||||||
|
snapshot = enrichSnapshotForViewer(snapshot)
|
||||||
body, err := viewer.RenderHTML(snapshot, h.opts.Title)
|
body, err := viewer.RenderHTML(snapshot, h.opts.Title)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
|||||||
@@ -1016,6 +1016,39 @@ func TestViewerRendersLatestSnapshot(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestViewerRendersDerivedStorageBlockFormat(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "audit.json")
|
||||||
|
body := `{
|
||||||
|
"collected_at":"2026-04-29T00:05:00Z",
|
||||||
|
"hardware":{
|
||||||
|
"board":{"serial_number":"SERIAL-NEW"},
|
||||||
|
"storage":[
|
||||||
|
{
|
||||||
|
"serial_number":"DISK-1",
|
||||||
|
"model":"Test NVMe",
|
||||||
|
"logical_block_size_bytes":512,
|
||||||
|
"physical_block_size_bytes":4096,
|
||||||
|
"metadata_bytes_per_block":8
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}`
|
||||||
|
if err := os.WriteFile(path, []byte(body), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
handler := NewHandler(HandlerOptions{AuditPath: path})
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/viewer", nil))
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status=%d", rec.Code)
|
||||||
|
}
|
||||||
|
if !strings.Contains(rec.Body.String(), "512+8") {
|
||||||
|
t.Fatalf("viewer body missing derived block format: %s", rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestAuditJSONServesLatestSnapshot(t *testing.T) {
|
func TestAuditJSONServesLatestSnapshot(t *testing.T) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
path := filepath.Join(dir, "audit.json")
|
path := filepath.Join(dir, "audit.json")
|
||||||
@@ -1038,6 +1071,36 @@ func TestAuditJSONServesLatestSnapshot(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestAuditJSONDoesNotInjectDerivedStorageBlockFormat(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "audit.json")
|
||||||
|
body := `{
|
||||||
|
"hardware":{
|
||||||
|
"board":{"serial_number":"SERIAL-API"},
|
||||||
|
"storage":[
|
||||||
|
{
|
||||||
|
"serial_number":"DISK-1",
|
||||||
|
"logical_block_size_bytes":512,
|
||||||
|
"metadata_bytes_per_block":8
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}`
|
||||||
|
if err := os.WriteFile(path, []byte(body), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
handler := NewHandler(HandlerOptions{AuditPath: path})
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/audit.json", nil))
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status=%d", rec.Code)
|
||||||
|
}
|
||||||
|
if strings.Contains(rec.Body.String(), "block_format") {
|
||||||
|
t.Fatalf("audit.json should remain contract-only: %s", rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestMissingAuditJSONReturnsNotFound(t *testing.T) {
|
func TestMissingAuditJSONReturnsNotFound(t *testing.T) {
|
||||||
handler := NewHandler(HandlerOptions{AuditPath: "/missing/audit.json"})
|
handler := NewHandler(HandlerOptions{AuditPath: "/missing/audit.json"})
|
||||||
rec := httptest.NewRecorder()
|
rec := httptest.NewRecorder()
|
||||||
|
|||||||
62
audit/internal/webui/viewer_snapshot.go
Normal file
62
audit/internal/webui/viewer_snapshot.go
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
package webui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"strconv"
|
||||||
|
)
|
||||||
|
|
||||||
|
func enrichSnapshotForViewer(snapshot []byte) []byte {
|
||||||
|
if len(snapshot) == 0 {
|
||||||
|
return snapshot
|
||||||
|
}
|
||||||
|
var root map[string]any
|
||||||
|
if err := json.Unmarshal(snapshot, &root); err != nil {
|
||||||
|
return snapshot
|
||||||
|
}
|
||||||
|
hardware, _ := root["hardware"].(map[string]any)
|
||||||
|
if len(hardware) == 0 {
|
||||||
|
return snapshot
|
||||||
|
}
|
||||||
|
storage, _ := hardware["storage"].([]any)
|
||||||
|
if len(storage) == 0 {
|
||||||
|
return snapshot
|
||||||
|
}
|
||||||
|
changed := false
|
||||||
|
for _, item := range storage {
|
||||||
|
row, _ := item.(map[string]any)
|
||||||
|
if len(row) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, exists := row["block_format"]; exists {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
logical, okLogical := jsonNumberToInt64(row["logical_block_size_bytes"])
|
||||||
|
metadata, okMetadata := jsonNumberToInt64(row["metadata_bytes_per_block"])
|
||||||
|
if !okLogical || !okMetadata || logical <= 0 || metadata < 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
row["block_format"] = strconv.FormatInt(logical, 10) + "+" + strconv.FormatInt(metadata, 10)
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
if !changed {
|
||||||
|
return snapshot
|
||||||
|
}
|
||||||
|
out, err := json.Marshal(root)
|
||||||
|
if err != nil {
|
||||||
|
return snapshot
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func jsonNumberToInt64(v any) (int64, bool) {
|
||||||
|
switch x := v.(type) {
|
||||||
|
case float64:
|
||||||
|
return int64(x), true
|
||||||
|
case int64:
|
||||||
|
return x, true
|
||||||
|
case int:
|
||||||
|
return int64(x), true
|
||||||
|
default:
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
}
|
||||||
2
bible
2
bible
Submodule bible updated: 1d89a4918e...d2600f1279
@@ -10,4 +10,4 @@ Generic engineering rules live in `bible/rules/patterns/`.
|
|||||||
| `architecture/system-overview.md` | What bee does, scope, tech stack |
|
| `architecture/system-overview.md` | What bee does, scope, tech stack |
|
||||||
| `architecture/runtime-flows.md` | Boot sequence, audit flow, service order |
|
| `architecture/runtime-flows.md` | Boot sequence, audit flow, service order |
|
||||||
| `docs/hardware-ingest-contract.md` | Current Reanimator hardware ingest JSON contract |
|
| `docs/hardware-ingest-contract.md` | Current Reanimator hardware ingest JSON contract |
|
||||||
| `decisions/` | Architectural decision log |
|
| `decisions/` | Architectural decision log, including read-only submodule policy |
|
||||||
|
|||||||
@@ -58,6 +58,8 @@ Fills gaps where Redfish/logpile is blind:
|
|||||||
- `bee` should populate current component state, hardware inventory, telemetry, and `status_checked_at`.
|
- `bee` should populate current component state, hardware inventory, telemetry, and `status_checked_at`.
|
||||||
- Historical status transitions and component replacement logic belong to the centralized ingest/lifecycle system, not to `bee`.
|
- Historical status transitions and component replacement logic belong to the centralized ingest/lifecycle system, not to `bee`.
|
||||||
- Contract fields that have no honest local source on a generic Linux host may remain empty.
|
- Contract fields that have no honest local source on a generic Linux host may remain empty.
|
||||||
|
- Embedded submodules such as `internal/chart/` and `bible/` are read-only for `bee` feature work.
|
||||||
|
- If the UI needs extra information, `bee` must emit it through the standard audit JSON contract rather than patching `chart`.
|
||||||
|
|
||||||
## Tech stack
|
## Tech stack
|
||||||
|
|
||||||
@@ -101,7 +103,7 @@ Fills gaps where Redfish/logpile is blind:
|
|||||||
| `iso/builder/` | ISO build scripts and `live-build` profile |
|
| `iso/builder/` | ISO build scripts and `live-build` profile |
|
||||||
| `iso/overlay/` | Source overlay copied into a staged build overlay |
|
| `iso/overlay/` | Source overlay copied into a staged build overlay |
|
||||||
| `iso/vendor/` | Optional pre-built vendor binaries (storcli64, sas2ircu, sas3ircu, arcconf, ssacli, …) |
|
| `iso/vendor/` | Optional pre-built vendor binaries (storcli64, sas2ircu, sas3ircu, arcconf, ssacli, …) |
|
||||||
| `internal/chart/` | Git submodule with `reanimator/chart`, embedded into `bee web` |
|
| `internal/chart/` | Git submodule with `reanimator/chart`, embedded into `bee web`; update by submodule pointer only, never by local `bee`-specific edits |
|
||||||
| `iso/builder/VERSIONS` | Pinned versions: Debian, Go, NVIDIA driver, kernel ABI |
|
| `iso/builder/VERSIONS` | Pinned versions: Debian, Go, NVIDIA driver, kernel ABI |
|
||||||
| `iso/builder/smoketest.sh` | Post-boot smoke test — run via SSH to verify live ISO |
|
| `iso/builder/smoketest.sh` | Post-boot smoke test — run via SSH to verify live ISO |
|
||||||
| `iso/overlay/etc/profile.d/bee.sh` | tty1 welcome message with web UI URLs |
|
| `iso/overlay/etc/profile.d/bee.sh` | tty1 welcome message with web UI URLs |
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
# Decision: Treat embedded submodules as read-only
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
`bee` embeds external git submodules such as:
|
||||||
|
|
||||||
|
- `internal/chart/` — `reanimator/chart`, a generic read-only viewer for Reanimator JSON snapshots
|
||||||
|
- `bible/` — shared engineering rules and contracts
|
||||||
|
|
||||||
|
These repositories are reused by other projects. A local feature request in `bee`
|
||||||
|
must not be solved by silently changing shared submodule behavior.
|
||||||
|
|
||||||
|
The concrete failure mode here was attempting to add project-specific storage
|
||||||
|
telemetry presentation by editing `internal/chart/`. That couples a shared viewer
|
||||||
|
to one host application's needs and creates hidden cross-project regressions.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
Embedded submodules are read-only from the point of view of `bee`.
|
||||||
|
|
||||||
|
- Do not implement `bee`-specific behavior by editing `internal/chart/`.
|
||||||
|
- Do not implement `bee`-specific behavior by editing `bible/`.
|
||||||
|
- If `bee` needs new data in the report, produce it in the standard audit JSON
|
||||||
|
emitted by `bee` itself.
|
||||||
|
- `chart` must continue to consume the canonical snapshot as an external viewer,
|
||||||
|
without host-specific forks.
|
||||||
|
- Updating a submodule pointer to an upstream commit is allowed.
|
||||||
|
- Carrying local unmerged submodule commits as part of a `bee` feature is forbidden.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- Audit/report features must be expressed through the contract in
|
||||||
|
`bible-local/docs/hardware-ingest-contract.md`.
|
||||||
|
- `bee` owns collection, normalization, and serialization of storage telemetry in
|
||||||
|
`hardware.storage[]`.
|
||||||
|
- `chart` remains a pure visualization module that reads the snapshot it is given.
|
||||||
|
- If a capability is genuinely missing in a shared submodule, it must be proposed
|
||||||
|
and landed upstream as a generic change first, then pulled into `bee` via a
|
||||||
|
normal submodule update.
|
||||||
@@ -6,3 +6,4 @@ One file per decision, named `YYYY-MM-DD-short-topic.md`.
|
|||||||
|---|---|---|
|
|---|---|---|
|
||||||
| 2026-03-05 | Use NVIDIA proprietary driver | active |
|
| 2026-03-05 | Use NVIDIA proprietary driver | active |
|
||||||
| 2026-04-01 | Treat memtest as explicit ISO content | active |
|
| 2026-04-01 | Treat memtest as explicit ISO content | active |
|
||||||
|
| 2026-04-29 | Treat embedded submodules as read-only | active |
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
---
|
---
|
||||||
title: Hardware Ingest JSON Contract
|
title: Hardware Ingest JSON Contract
|
||||||
version: "2.7"
|
version: "2.10"
|
||||||
updated: "2026-03-15"
|
updated: "2026-04-29"
|
||||||
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.7** · Дата: **2026-03-15**
|
Версия: **2.10** · Дата: **2026-04-29**
|
||||||
|
|
||||||
Документ описывает формат JSON для передачи данных об аппаратном обеспечении серверов в систему **Reanimator** (управление жизненным циклом аппаратного обеспечения).
|
Документ описывает формат JSON для передачи данных об аппаратном обеспечении серверов в систему **Reanimator** (управление жизненным циклом аппаратного обеспечения).
|
||||||
Предназначен для разработчиков смежных систем (Redfish-коллекторов, агентов мониторинга, CMDB-экспортёров) и может быть включён в документацию интегрируемых проектов.
|
Предназначен для разработчиков смежных систем (Redfish-коллекторов, агентов мониторинга, CMDB-экспортёров) и может быть включён в документацию интегрируемых проектов.
|
||||||
@@ -22,6 +22,9 @@ language: ru
|
|||||||
|
|
||||||
| Версия | Дата | Изменения |
|
| Версия | Дата | Изменения |
|
||||||
|--------|------|-----------|
|
|--------|------|-----------|
|
||||||
|
| 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.8 | 2026-03-15 | Поле `location` удалено из всех `sensors.*`; сенсоры передаются только по `name` и измеренным значениям |
|
||||||
| 2.7 | 2026-03-15 | Явно запрещён синтез данных в `event_logs`; интеграторы не должны придумывать серийные номера компонентов, если источник их не отдал |
|
| 2.7 | 2026-03-15 | Явно запрещён синтез данных в `event_logs`; интеграторы не должны придумывать серийные номера компонентов, если источник их не отдал |
|
||||||
| 2.6 | 2026-03-15 | Добавлена необязательная секция `event_logs` для dedup/upsert логов `host` / `bmc` / `redfish` вне history timeline |
|
| 2.6 | 2026-03-15 | Добавлена необязательная секция `event_logs` для dedup/upsert логов `host` / `bmc` / `redfish` вне history timeline |
|
||||||
| 2.5 | 2026-03-15 | Добавлено общее необязательное поле `manufactured_year_week` для компонентных секций (`YYYY-Www`) |
|
| 2.5 | 2026-03-15 | Добавлено общее необязательное поле `manufactured_year_week` для компонентных секций (`YYYY-Www`) |
|
||||||
@@ -132,7 +135,8 @@ GET /ingest/hardware/jobs/{job_id}
|
|||||||
"pcie_devices": [ ... ],
|
"pcie_devices": [ ... ],
|
||||||
"power_supplies": [ ... ],
|
"power_supplies": [ ... ],
|
||||||
"sensors": { ... },
|
"sensors": { ... },
|
||||||
"event_logs": [ ... ]
|
"event_logs": [ ... ],
|
||||||
|
"platform_config": { ... }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
@@ -343,6 +347,9 @@ GET /ingest/hardware/jobs/{job_id}
|
|||||||
| `type` | string | нет | Тип: `NVMe`, `SSD`, `HDD` |
|
| `type` | string | нет | Тип: `NVMe`, `SSD`, `HDD` |
|
||||||
| `interface` | string | нет | Интерфейс: `NVMe`, `SATA`, `SAS` |
|
| `interface` | string | нет | Интерфейс: `NVMe`, `SATA`, `SAS` |
|
||||||
| `size_gb` | int | нет | Размер в ГБ |
|
| `size_gb` | int | нет | Размер в ГБ |
|
||||||
|
| `logical_block_size_bytes` | int64 | нет | Логический размер пользовательского блока данных, например `512` или `4096` |
|
||||||
|
| `physical_block_size_bytes` | int64 | нет | Физический размер блока, если известен, например `4096` |
|
||||||
|
| `metadata_bytes_per_block` | int64 | нет | Metadata / protection bytes на логический блок, например `0` или `8` |
|
||||||
| `temperature_c` | float | нет | Температура накопителя, °C (telemetry) |
|
| `temperature_c` | float | нет | Температура накопителя, °C (telemetry) |
|
||||||
| `power_on_hours` | int64 | нет | Время работы, часы |
|
| `power_on_hours` | int64 | нет | Время работы, часы |
|
||||||
| `power_cycles` | int64 | нет | Количество циклов питания |
|
| `power_cycles` | int64 | нет | Количество циклов питания |
|
||||||
@@ -363,6 +370,11 @@ GET /ingest/hardware/jobs/{job_id}
|
|||||||
|
|
||||||
Диск без `serial_number` игнорируется. Изменение `firmware` создаёт событие `FIRMWARE_CHANGED`.
|
Диск без `serial_number` игнорируется. Изменение `firmware` создаёт событие `FIRMWARE_CHANGED`.
|
||||||
|
|
||||||
|
Формат вида `512+8` в контракт не добавляется отдельным строковым полем. Если источник знает такую форму, он должен передавать её как:
|
||||||
|
- `logical_block_size_bytes = 512`
|
||||||
|
- `metadata_bytes_per_block = 8`
|
||||||
|
- `physical_block_size_bytes = 512` или `4096`, если известен физический размер блока
|
||||||
|
|
||||||
```json
|
```json
|
||||||
"storage": [
|
"storage": [
|
||||||
{
|
{
|
||||||
@@ -370,6 +382,9 @@ GET /ingest/hardware/jobs/{job_id}
|
|||||||
"type": "NVMe",
|
"type": "NVMe",
|
||||||
"model": "INTEL SSDPF2KX076T1",
|
"model": "INTEL SSDPF2KX076T1",
|
||||||
"size_gb": 7680,
|
"size_gb": 7680,
|
||||||
|
"logical_block_size_bytes": 512,
|
||||||
|
"physical_block_size_bytes": 4096,
|
||||||
|
"metadata_bytes_per_block": 8,
|
||||||
"temperature_c": 38.5,
|
"temperature_c": 38.5,
|
||||||
"power_on_hours": 12450,
|
"power_on_hours": 12450,
|
||||||
"unsafe_shutdowns": 3,
|
"unsafe_shutdowns": 3,
|
||||||
@@ -592,7 +607,6 @@ PSU без `serial_number` игнорируется.
|
|||||||
| Поле | Тип | Обязательно | Описание |
|
| Поле | Тип | Обязательно | Описание |
|
||||||
|------|-----|-------------|----------|
|
|------|-----|-------------|----------|
|
||||||
| `name` | string | **да** | Уникальное имя сенсора в рамках секции |
|
| `name` | string | **да** | Уникальное имя сенсора в рамках секции |
|
||||||
| `location` | string | нет | Физическое расположение |
|
|
||||||
| `rpm` | int | нет | Обороты, RPM |
|
| `rpm` | int | нет | Обороты, RPM |
|
||||||
| `status` | string | нет | Статус: `OK`, `Warning`, `Critical`, `Unknown` |
|
| `status` | string | нет | Статус: `OK`, `Warning`, `Critical`, `Unknown` |
|
||||||
|
|
||||||
@@ -601,7 +615,6 @@ PSU без `serial_number` игнорируется.
|
|||||||
| Поле | Тип | Обязательно | Описание |
|
| Поле | Тип | Обязательно | Описание |
|
||||||
|------|-----|-------------|----------|
|
|------|-----|-------------|----------|
|
||||||
| `name` | string | **да** | Уникальное имя сенсора |
|
| `name` | string | **да** | Уникальное имя сенсора |
|
||||||
| `location` | string | нет | Физическое расположение |
|
|
||||||
| `voltage_v` | float | нет | Напряжение, В |
|
| `voltage_v` | float | нет | Напряжение, В |
|
||||||
| `current_a` | float | нет | Ток, А |
|
| `current_a` | float | нет | Ток, А |
|
||||||
| `power_w` | float | нет | Мощность, Вт |
|
| `power_w` | float | нет | Мощность, Вт |
|
||||||
@@ -612,7 +625,6 @@ PSU без `serial_number` игнорируется.
|
|||||||
| Поле | Тип | Обязательно | Описание |
|
| Поле | Тип | Обязательно | Описание |
|
||||||
|------|-----|-------------|----------|
|
|------|-----|-------------|----------|
|
||||||
| `name` | string | **да** | Уникальное имя сенсора |
|
| `name` | string | **да** | Уникальное имя сенсора |
|
||||||
| `location` | string | нет | Физическое расположение |
|
|
||||||
| `celsius` | float | нет | Температура, °C |
|
| `celsius` | float | нет | Температура, °C |
|
||||||
| `threshold_warning_celsius` | float | нет | Порог Warning, °C |
|
| `threshold_warning_celsius` | float | нет | Порог Warning, °C |
|
||||||
| `threshold_critical_celsius` | float | нет | Порог Critical, °C |
|
| `threshold_critical_celsius` | float | нет | Порог Critical, °C |
|
||||||
@@ -623,29 +635,29 @@ PSU без `serial_number` игнорируется.
|
|||||||
| Поле | Тип | Обязательно | Описание |
|
| Поле | Тип | Обязательно | Описание |
|
||||||
|------|-----|-------------|----------|
|
|------|-----|-------------|----------|
|
||||||
| `name` | string | **да** | Уникальное имя сенсора |
|
| `name` | string | **да** | Уникальное имя сенсора |
|
||||||
| `location` | string | нет | Физическое расположение |
|
|
||||||
| `value` | float | нет | Значение |
|
| `value` | float | нет | Значение |
|
||||||
| `unit` | string | нет | Единица измерения |
|
| `unit` | string | нет | Единица измерения |
|
||||||
| `status` | string | нет | Статус |
|
| `status` | string | нет | Статус |
|
||||||
|
|
||||||
**Правила sensors:**
|
**Правила sensors:**
|
||||||
- Идентификатор сенсора: пара `(sensor_type, name)`. Дубли в одном payload — берётся первое вхождение.
|
- Идентификатор сенсора: пара `(sensor_type, name)`. Дубли в одном payload — берётся первое вхождение.
|
||||||
|
- `location` для сенсоров передавать не нужно и не следует: в Reanimator location/slot используется только для проверки перемещения и установки компонентов, а не для last-known-value sensor ingest.
|
||||||
- Сенсоры без `name` игнорируются.
|
- Сенсоры без `name` игнорируются.
|
||||||
- При каждом импорте значения перезаписываются (upsert по ключу).
|
- При каждом импорте значения перезаписываются (upsert по ключу).
|
||||||
|
|
||||||
```json
|
```json
|
||||||
"sensors": {
|
"sensors": {
|
||||||
"fans": [
|
"fans": [
|
||||||
{ "name": "FAN1", "location": "Front", "rpm": 4200, "status": "OK" },
|
{ "name": "FAN1", "rpm": 4200, "status": "OK" },
|
||||||
{ "name": "FAN_CPU0", "location": "CPU0", "rpm": 5600, "status": "OK" }
|
{ "name": "FAN_CPU0", "rpm": 5600, "status": "OK" }
|
||||||
],
|
],
|
||||||
"power": [
|
"power": [
|
||||||
{ "name": "12V Rail", "location": "Mainboard", "voltage_v": 12.06, "status": "OK" },
|
{ "name": "12V Rail", "voltage_v": 12.06, "status": "OK" },
|
||||||
{ "name": "PSU0 Input", "location": "PSU0", "voltage_v": 215.25, "current_a": 0.64, "power_w": 137.0, "status": "OK" }
|
{ "name": "PSU0 Input", "voltage_v": 215.25, "current_a": 0.64, "power_w": 137.0, "status": "OK" }
|
||||||
],
|
],
|
||||||
"temperatures": [
|
"temperatures": [
|
||||||
{ "name": "CPU0 Temp", "location": "CPU0", "celsius": 46.0, "threshold_warning_celsius": 80.0, "threshold_critical_celsius": 95.0, "status": "OK" },
|
{ "name": "CPU0 Temp", "celsius": 46.0, "threshold_warning_celsius": 80.0, "threshold_critical_celsius": 95.0, "status": "OK" },
|
||||||
{ "name": "Inlet Temp", "location": "Front", "celsius": 22.0, "threshold_warning_celsius": 40.0, "threshold_critical_celsius": 50.0, "status": "OK" }
|
{ "name": "Inlet Temp", "celsius": 22.0, "threshold_warning_celsius": 40.0, "threshold_critical_celsius": 50.0, "status": "OK" }
|
||||||
],
|
],
|
||||||
"other": [
|
"other": [
|
||||||
{ "name": "System Humidity", "value": 38.5, "unit": "%", "status": "OK" }
|
{ "name": "System Humidity", "value": 38.5, "unit": "%", "status": "OK" }
|
||||||
@@ -655,6 +667,31 @@ PSU без `serial_number` игнорируется.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Секция platform_config
|
||||||
|
|
||||||
|
Необязательный объект с произвольными ключами — настройки платформы как есть из источника (BIOS, Redfish, IPMI).
|
||||||
|
|
||||||
|
| Поле | Тип | Обязательно | Описание |
|
||||||
|
|------|-----|-------------|----------|
|
||||||
|
| `platform_config` | object | нет | Произвольный объект: ключи — строки, значения — строки, числа, булевы |
|
||||||
|
|
||||||
|
**Правила platform_config:**
|
||||||
|
- Содержимое объекта не валидируется: передавайте параметры как есть.
|
||||||
|
- При каждом импорте хранится latest-snapshot per machine; история изменений по каждому ключу накапливается отдельно.
|
||||||
|
- Если секция отсутствует или равна `null` — данные платформы не обновляются.
|
||||||
|
|
||||||
|
```json
|
||||||
|
"platform_config": {
|
||||||
|
"SecureBoot": "Enabled",
|
||||||
|
"BiosVersion": "06.08.05",
|
||||||
|
"TpmEnabled": true,
|
||||||
|
"NumaEnabled": false,
|
||||||
|
"HyperThreading": "Enabled"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Обработка статусов компонентов
|
## Обработка статусов компонентов
|
||||||
|
|
||||||
| Статус | Поведение |
|
| Статус | Поведение |
|
||||||
@@ -787,6 +824,12 @@ PSU без `serial_number` игнорируется.
|
|||||||
"other": [
|
"other": [
|
||||||
{ "name": "System Humidity", "value": 38.5, "unit": "%" }
|
{ "name": "System Humidity", "value": 38.5, "unit": "%" }
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
"platform_config": {
|
||||||
|
"SecureBoot": "Enabled",
|
||||||
|
"BiosVersion": "06.08.05",
|
||||||
|
"TpmEnabled": true,
|
||||||
|
"HyperThreading": "Enabled"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
31
bible-local/rules/patterns/ascii-safe-text/contract.md
Normal file
31
bible-local/rules/patterns/ascii-safe-text/contract.md
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
# Contract: ASCII-Safe Text in Scripts and Boot Configs
|
||||||
|
|
||||||
|
Version: 1.0
|
||||||
|
|
||||||
|
## Principle
|
||||||
|
|
||||||
|
Shell scripts, bootloader configs, and any text rendered on serial/SOL consoles must use only printable ASCII characters. Non-ASCII Unicode — including typographic punctuation such as the em-dash (U+2014 `—`), en-dash (U+2013 `–`), curly quotes, and ellipsis (U+2026 `…`) — breaks rendering on serial terminals, GRUB text/serial mode, IPMI SOL, and tooling that assumes ASCII.
|
||||||
|
|
||||||
|
## Rules
|
||||||
|
|
||||||
|
- Never use em-dash (`—`) or en-dash (`–`) in any shell script, GRUB config, syslinux/isolinux config, or service unit file. Use ASCII double-hyphen `--` or single hyphen `-` instead.
|
||||||
|
- Never use curly quotes (`"` `"` `'` `'`) in shell scripts or configs. Use straight quotes `"` and `'`.
|
||||||
|
- Never use the Unicode ellipsis (`…`). Use `...`.
|
||||||
|
- GRUB `menuentry` and `submenu` titles must be ASCII-only — GRUB serial terminal output is ASCII; non-ASCII characters render as garbage or are dropped.
|
||||||
|
- Comments in GRUB theme files (`.txt`) must also be ASCII-only, as GRUB may parse the entire file.
|
||||||
|
|
||||||
|
## Why
|
||||||
|
|
||||||
|
GRUB renders menus over both `gfxterm` (graphical, Unicode-capable) and `serial` (ASCII-only) simultaneously when `terminal_output gfxterm serial` is set. The serial output — used by IPMI SOL and BMC remote consoles — cannot display multi-byte UTF-8 sequences and shows raw bytes or drops characters. A menuentry title `"EASY-BEE — GSP=off"` appears as `"EASY-BEE â€" GSP=off"` or `"EASY-BEE GSP=off"` on SOL, making the menu unreadable.
|
||||||
|
|
||||||
|
## Anti-patterns
|
||||||
|
|
||||||
|
- `menuentry "EASY-BEE — GSP=off"` — em-dash in GRUB title
|
||||||
|
- `# bee logo — centered` — em-dash in GRUB theme comment
|
||||||
|
- `echo "done — reboot"` in a shell script displayed over serial
|
||||||
|
|
||||||
|
## Correct form
|
||||||
|
|
||||||
|
- `menuentry "EASY-BEE -- GSP=off"`
|
||||||
|
- `# bee logo - centered`
|
||||||
|
- `echo "done - reboot"`
|
||||||
@@ -31,10 +31,10 @@ Build with explicit SSH keys baked into the ISO:
|
|||||||
sh iso/builder/build-in-container.sh --authorized-keys ~/.ssh/id_ed25519.pub
|
sh iso/builder/build-in-container.sh --authorized-keys ~/.ssh/id_ed25519.pub
|
||||||
```
|
```
|
||||||
|
|
||||||
Rebuild the builder image:
|
Force a clean rebuild of the builder image and build caches:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
sh iso/builder/build-in-container.sh --rebuild-image
|
sh iso/builder/build-in-container.sh --clean-build
|
||||||
```
|
```
|
||||||
|
|
||||||
Use a custom cache directory:
|
Use a custom cache directory:
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ IMAGE_TAG="${BEE_BUILDER_IMAGE:-bee-iso-builder}"
|
|||||||
BUILDER_PLATFORM="${BEE_BUILDER_PLATFORM:-linux/amd64}"
|
BUILDER_PLATFORM="${BEE_BUILDER_PLATFORM:-linux/amd64}"
|
||||||
CACHE_DIR="${BEE_BUILDER_CACHE_DIR:-${REPO_ROOT}/dist/container-cache}"
|
CACHE_DIR="${BEE_BUILDER_CACHE_DIR:-${REPO_ROOT}/dist/container-cache}"
|
||||||
AUTH_KEYS=""
|
AUTH_KEYS=""
|
||||||
REBUILD_IMAGE=0
|
|
||||||
CLEAN_CACHE=0
|
CLEAN_CACHE=0
|
||||||
VARIANT="all"
|
VARIANT="all"
|
||||||
|
|
||||||
@@ -22,17 +21,12 @@ while [ $# -gt 0 ]; do
|
|||||||
CACHE_DIR="$2"
|
CACHE_DIR="$2"
|
||||||
shift 2
|
shift 2
|
||||||
;;
|
;;
|
||||||
--rebuild-image)
|
|
||||||
REBUILD_IMAGE=1
|
|
||||||
shift
|
|
||||||
;;
|
|
||||||
--authorized-keys)
|
--authorized-keys)
|
||||||
AUTH_KEYS="$2"
|
AUTH_KEYS="$2"
|
||||||
shift 2
|
shift 2
|
||||||
;;
|
;;
|
||||||
--clean-build)
|
--clean-build)
|
||||||
CLEAN_CACHE=1
|
CLEAN_CACHE=1
|
||||||
REBUILD_IMAGE=1
|
|
||||||
shift
|
shift
|
||||||
;;
|
;;
|
||||||
--variant)
|
--variant)
|
||||||
@@ -41,7 +35,7 @@ while [ $# -gt 0 ]; do
|
|||||||
;;
|
;;
|
||||||
*)
|
*)
|
||||||
echo "unknown arg: $1" >&2
|
echo "unknown arg: $1" >&2
|
||||||
echo "usage: $0 [--cache-dir /path] [--rebuild-image] [--clean-build] [--authorized-keys /path/to/authorized_keys] [--variant nvidia|nvidia-legacy|amd|nogpu|all]" >&2
|
echo "usage: $0 [--cache-dir /path] [--clean-build] [--authorized-keys /path/to/authorized_keys] [--variant nvidia|nvidia-legacy|amd|nogpu|all]" >&2
|
||||||
exit 1
|
exit 1
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
@@ -105,7 +99,7 @@ image_matches_platform() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
NEED_BUILD_IMAGE=0
|
NEED_BUILD_IMAGE=0
|
||||||
if [ "$REBUILD_IMAGE" = "1" ]; then
|
if [ "$CLEAN_CACHE" = "1" ]; then
|
||||||
NEED_BUILD_IMAGE=1
|
NEED_BUILD_IMAGE=1
|
||||||
elif ! "$CONTAINER_TOOL" image inspect "${IMAGE_REF}" >/dev/null 2>&1; then
|
elif ! "$CONTAINER_TOOL" image inspect "${IMAGE_REF}" >/dev/null 2>&1; then
|
||||||
NEED_BUILD_IMAGE=1
|
NEED_BUILD_IMAGE=1
|
||||||
|
|||||||
@@ -848,6 +848,73 @@ reset_live_build_stage() {
|
|||||||
done
|
done
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Marker written after every successful full lb build for this variant
|
||||||
|
FULL_BUILD_MARKER="${BUILD_WORK_DIR}/.bee-full-build-marker"
|
||||||
|
|
||||||
|
# Returns 0 if full lb build is needed, 1 if fast-path is safe.
|
||||||
|
# Fast-path is safe when only light files changed since the last full build
|
||||||
|
# (Go source, overlay scripts/configs). Heavy changes (VERSIONS, package lists,
|
||||||
|
# hooks, archives, Dockerfile, auto/config) require a full lb build.
|
||||||
|
needs_full_build() {
|
||||||
|
[ -f "${FULL_BUILD_MARKER}" ] || return 0
|
||||||
|
[ -f "${BUILD_WORK_DIR}/binary/live/filesystem.squashfs" ] || return 0
|
||||||
|
[ -f "${BUILD_WORK_DIR}/live-image-amd64.hybrid.iso" ] || return 0
|
||||||
|
|
||||||
|
_heavy=$(find \
|
||||||
|
"${BUILDER_DIR}/VERSIONS" \
|
||||||
|
"${BUILDER_DIR}/auto/config" \
|
||||||
|
"${BUILDER_DIR}/Dockerfile" \
|
||||||
|
"${BUILDER_DIR}/config/package-lists" \
|
||||||
|
"${BUILDER_DIR}/config/hooks" \
|
||||||
|
"${BUILDER_DIR}/config/archives" \
|
||||||
|
"${BUILDER_DIR}/config/bootloaders" \
|
||||||
|
-newer "${FULL_BUILD_MARKER}" 2>/dev/null | head -1)
|
||||||
|
|
||||||
|
if [ -n "$_heavy" ]; then
|
||||||
|
echo "=== full build required: heavy config changed: $(basename "$_heavy") ==="
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# Fast-path: unsquash existing filesystem, rsync overlay on top, repack.
|
||||||
|
# Requires ~10 GB free in BEE_CACHE_DIR for the unpacked squashfs.
|
||||||
|
fast_path_repack_squashfs() {
|
||||||
|
_sq="${BUILD_WORK_DIR}/binary/live/filesystem.squashfs"
|
||||||
|
_tmp="${BEE_CACHE_DIR}/fast-unsquash-${BUILD_VARIANT}"
|
||||||
|
echo "=== fast-path: unsquash ($(du -sh "$_sq" | cut -f1) compressed) ==="
|
||||||
|
rm -rf "$_tmp"
|
||||||
|
unsquashfs -d "$_tmp" "$_sq"
|
||||||
|
echo "=== fast-path: syncing overlay stage ==="
|
||||||
|
rsync -a --checksum "${OVERLAY_STAGE_DIR}/" "$_tmp/"
|
||||||
|
echo "=== fast-path: repacking squashfs ==="
|
||||||
|
_sq_new="${_sq}.new"
|
||||||
|
rm -f "$_sq_new"
|
||||||
|
mksquashfs "$_tmp" "$_sq_new" -comp zstd -b 1048576 -noappend -no-progress
|
||||||
|
mv "$_sq_new" "$_sq"
|
||||||
|
rm -rf "$_tmp"
|
||||||
|
echo "=== fast-path: squashfs repacked ($(du -sh "$_sq" | cut -f1)) ==="
|
||||||
|
}
|
||||||
|
|
||||||
|
# Fast-path: rebuild ISO by replacing only live/filesystem.squashfs via xorriso.
|
||||||
|
# Boot structure (El Torito, EFI, MBR hybrid) is replayed from the prior ISO.
|
||||||
|
fast_path_rebuild_iso() {
|
||||||
|
_sq="${BUILD_WORK_DIR}/binary/live/filesystem.squashfs"
|
||||||
|
_prior="${BUILD_WORK_DIR}/live-image-amd64.hybrid.iso"
|
||||||
|
_new="${BUILD_WORK_DIR}/live-image-amd64.hybrid.iso.new"
|
||||||
|
echo "=== fast-path: rebuilding ISO with xorriso ==="
|
||||||
|
rm -f "$_new"
|
||||||
|
xorriso \
|
||||||
|
-indev "$_prior" \
|
||||||
|
-outdev "$_new" \
|
||||||
|
-map "$_sq" /live/filesystem.squashfs \
|
||||||
|
-boot_image any replay \
|
||||||
|
-commit
|
||||||
|
mv "$_new" "$_prior"
|
||||||
|
echo "=== fast-path: ISO rebuilt ==="
|
||||||
|
}
|
||||||
|
|
||||||
recover_iso_memtest() {
|
recover_iso_memtest() {
|
||||||
lb_dir="$1"
|
lb_dir="$1"
|
||||||
iso_path="$2"
|
iso_path="$2"
|
||||||
@@ -1487,6 +1554,21 @@ if [ -f "${LB_INCLUDES}/root/.ssh/authorized_keys" ]; then
|
|||||||
chmod 600 "${LB_INCLUDES}/root/.ssh/authorized_keys"
|
chmod 600 "${LB_INCLUDES}/root/.ssh/authorized_keys"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# --- auto fast-path: squashfs surgery if only light files changed ---
|
||||||
|
if ! needs_full_build; then
|
||||||
|
echo "=== fast-path build (no heavy config changes since last full build) ==="
|
||||||
|
fast_path_repack_squashfs
|
||||||
|
fast_path_rebuild_iso
|
||||||
|
ISO_RAW="${LB_DIR}/live-image-amd64.hybrid.iso"
|
||||||
|
validate_iso_live_boot_entries "$ISO_RAW"
|
||||||
|
validate_iso_nvidia_runtime "$ISO_RAW"
|
||||||
|
cp "$ISO_RAW" "$ISO_OUT"
|
||||||
|
echo ""
|
||||||
|
echo "=== done (${BUILD_VARIANT}, fast-path) ==="
|
||||||
|
echo "ISO: $ISO_OUT"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
# --- build ISO using live-build ---
|
# --- build ISO using live-build ---
|
||||||
echo ""
|
echo ""
|
||||||
echo "=== building ISO (variant: ${BUILD_VARIANT}) ==="
|
echo "=== building ISO (variant: ${BUILD_VARIANT}) ==="
|
||||||
@@ -1535,6 +1617,7 @@ if [ -f "$ISO_RAW" ]; then
|
|||||||
validate_iso_live_boot_entries "$ISO_RAW"
|
validate_iso_live_boot_entries "$ISO_RAW"
|
||||||
validate_iso_nvidia_runtime "$ISO_RAW"
|
validate_iso_nvidia_runtime "$ISO_RAW"
|
||||||
cp "$ISO_RAW" "$ISO_OUT"
|
cp "$ISO_RAW" "$ISO_OUT"
|
||||||
|
touch "${FULL_BUILD_MARKER}"
|
||||||
echo ""
|
echo ""
|
||||||
echo "=== done (${BUILD_VARIANT}) ==="
|
echo "=== done (${BUILD_VARIANT}) ==="
|
||||||
echo "ISO: $ISO_OUT"
|
echo "ISO: $ISO_OUT"
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 78 KiB After Width: | Height: | Size: 77 KiB |
@@ -47,18 +47,30 @@ vim-tiny
|
|||||||
mc
|
mc
|
||||||
htop
|
htop
|
||||||
nvtop
|
nvtop
|
||||||
btop
|
|
||||||
sudo
|
sudo
|
||||||
zstd
|
zstd
|
||||||
mstflint
|
mstflint
|
||||||
memtester
|
memtester
|
||||||
stress-ng
|
stress-ng
|
||||||
stressapptest
|
stressapptest
|
||||||
|
fio
|
||||||
|
iperf3
|
||||||
|
iotop
|
||||||
|
nload
|
||||||
|
tcpdump
|
||||||
|
hdparm
|
||||||
|
sysstat
|
||||||
|
lsscsi
|
||||||
|
sg3-utils
|
||||||
|
jq
|
||||||
|
curl
|
||||||
|
net-tools
|
||||||
|
|
||||||
# QR codes (for displaying audit results)
|
# QR codes (for displaying audit results)
|
||||||
qrencode
|
qrencode
|
||||||
|
|
||||||
# Local desktop (openbox + chromium kiosk)
|
# Local desktop (openbox + chromium kiosk)
|
||||||
|
gparted
|
||||||
openbox
|
openbox
|
||||||
tint2
|
tint2
|
||||||
feh
|
feh
|
||||||
|
|||||||
BIN
iso/vendor/arcconf
vendored
Executable file
BIN
iso/vendor/arcconf
vendored
Executable file
Binary file not shown.
BIN
iso/vendor/sas2ircu
vendored
Executable file
BIN
iso/vendor/sas2ircu
vendored
Executable file
Binary file not shown.
BIN
iso/vendor/sas3ircu
vendored
Executable file
BIN
iso/vendor/sas3ircu
vendored
Executable file
Binary file not shown.
BIN
iso/vendor/ssacli
vendored
Executable file
BIN
iso/vendor/ssacli
vendored
Executable file
Binary file not shown.
BIN
iso/vendor/storcli64
vendored
Executable file
BIN
iso/vendor/storcli64
vendored
Executable file
Binary file not shown.
@@ -1,74 +0,0 @@
|
|||||||
#!/bin/sh
|
|
||||||
# fetch-vendor.sh — download proprietary vendor utilities into iso/vendor.
|
|
||||||
#
|
|
||||||
# Usage:
|
|
||||||
# STORCLI_URL=... STORCLI_SHA256=... \
|
|
||||||
# SAS2IRCU_URL=... SAS2IRCU_SHA256=... \
|
|
||||||
# SAS3IRCU_URL=... SAS3IRCU_SHA256=... \
|
|
||||||
# MSTFLINT_URL=... MSTFLINT_SHA256=... \
|
|
||||||
# sh scripts/fetch-vendor.sh
|
|
||||||
|
|
||||||
set -eu
|
|
||||||
|
|
||||||
ROOT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
|
|
||||||
OUT_DIR="$ROOT_DIR/iso/vendor"
|
|
||||||
mkdir -p "$OUT_DIR"
|
|
||||||
|
|
||||||
need_cmd() {
|
|
||||||
command -v "$1" >/dev/null 2>&1 || { echo "ERROR: required command not found: $1" >&2; exit 1; }
|
|
||||||
}
|
|
||||||
|
|
||||||
need_cmd sha256sum
|
|
||||||
|
|
||||||
download_to() {
|
|
||||||
url="$1"
|
|
||||||
out="$2"
|
|
||||||
if command -v wget >/dev/null 2>&1; then
|
|
||||||
wget -O "$out" "$url"
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
if command -v curl >/dev/null 2>&1; then
|
|
||||||
curl -fsSL "$url" -o "$out"
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
echo "ERROR: required command not found: wget or curl" >&2
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
|
|
||||||
fetch_one() {
|
|
||||||
name="$1"
|
|
||||||
url="$2"
|
|
||||||
sha="$3"
|
|
||||||
|
|
||||||
if [ -z "$url" ] || [ -z "$sha" ]; then
|
|
||||||
echo "[vendor] skip $name (URL/SHA not provided)"
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
dst="$OUT_DIR/$name"
|
|
||||||
tmp="$dst.tmp"
|
|
||||||
|
|
||||||
echo "[vendor] downloading $name"
|
|
||||||
download_to "$url" "$tmp"
|
|
||||||
|
|
||||||
got=$(sha256sum "$tmp" | awk '{print $1}')
|
|
||||||
want=$(echo "$sha" | tr '[:upper:]' '[:lower:]')
|
|
||||||
if [ "$got" != "$want" ]; then
|
|
||||||
rm -f "$tmp"
|
|
||||||
echo "ERROR: checksum mismatch for $name" >&2
|
|
||||||
echo " got: $got" >&2
|
|
||||||
echo " want: $want" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
mv "$tmp" "$dst"
|
|
||||||
chmod +x "$dst" || true
|
|
||||||
echo "[vendor] ok: $name"
|
|
||||||
}
|
|
||||||
|
|
||||||
fetch_one "storcli64" "${STORCLI_URL:-}" "${STORCLI_SHA256:-}"
|
|
||||||
fetch_one "sas2ircu" "${SAS2IRCU_URL:-}" "${SAS2IRCU_SHA256:-}"
|
|
||||||
fetch_one "sas3ircu" "${SAS3IRCU_URL:-}" "${SAS3IRCU_SHA256:-}"
|
|
||||||
fetch_one "mstflint" "${MSTFLINT_URL:-}" "${MSTFLINT_SHA256:-}"
|
|
||||||
|
|
||||||
echo "[vendor] done. output dir: $OUT_DIR"
|
|
||||||
Reference in New Issue
Block a user