diff --git a/audit/cmd/bee/main.go b/audit/cmd/bee/main.go index 01b77bc..10c2ee0 100644 --- a/audit/cmd/bee/main.go +++ b/audit/cmd/bee/main.go @@ -20,6 +20,7 @@ import ( ) var Version = "dev" +var BuildCommit = "unknown" func buildLabel() string { label := strings.TrimSpace(Version) @@ -92,6 +93,10 @@ func run(args []string, stdout, stderr io.Writer) (exitCode int) { case "gpu-bandwidth-groups": return runGPUBandwidthGroups(args[1:], stdout, stderr) case "version", "--version", "-version": + if len(args) > 1 && args[1] == "--commit" { + fmt.Fprintln(stdout, BuildCommit) + return 0 + } fmt.Fprintln(stdout, Version) return 0 default: diff --git a/audit/internal/app/component_status_db.go b/audit/internal/app/component_status_db.go index 41b797d..0f35a86 100644 --- a/audit/internal/app/component_status_db.go +++ b/audit/internal/app/component_status_db.go @@ -232,6 +232,11 @@ func ApplySATResultToDB(db *ComponentStatusDB, target, archivePath string) { if overall == "" { return } + // An unsupported pack did not exercise a component. In particular, a + // non-GPU host must not acquire a fictitious "pcie:gpu:nvidia" record. + if overall == "UNSUPPORTED" { + return + } source := "sat:" + target dbStatus := satStatusToDBStatus(overall) diff --git a/audit/internal/app/support_bundle.go b/audit/internal/app/support_bundle.go index 1243c0d..7154dc3 100644 --- a/audit/internal/app/support_bundle.go +++ b/audit/internal/app/support_bundle.go @@ -642,6 +642,7 @@ func writeManifest(dst, exportDir, stageRoot string) error { } var body strings.Builder fmt.Fprintf(&body, "bee_version=%s\n", buildVersion()) + fmt.Fprintf(&body, "bee_git_commit=%s\n", buildCommit()) fmt.Fprintf(&body, "host=%s\n", hostnameOr("unknown")) fmt.Fprintf(&body, "generated_at_utc=%s\n", time.Now().UTC().Format(time.RFC3339)) fmt.Fprintf(&body, "export_dir=%s\n", exportDir) @@ -747,6 +748,16 @@ func buildVersion() string { return strings.TrimSpace(string(raw)) } +func buildCommit() string { + ctx, cancel := context.WithTimeout(context.Background(), defaultCommandTimeout) + defer cancel() + raw, err := exec.CommandContext(ctx, "bee", "version", "--commit").CombinedOutput() + if err != nil || strings.TrimSpace(string(raw)) == "" { + return "unknown" + } + return strings.TrimSpace(string(raw)) +} + func copyDirContents(srcDir, dstDir string) error { entries, err := os.ReadDir(srcDir) if err != nil { diff --git a/audit/internal/collector/dmesg_events.go b/audit/internal/collector/dmesg_events.go index fed6dfb..0c4e675 100644 --- a/audit/internal/collector/dmesg_events.go +++ b/audit/internal/collector/dmesg_events.go @@ -34,6 +34,20 @@ var dmesgErrorPatterns = []*regexp.Regexp{ regexp.MustCompile(`(?i)\bdisabled\b`), } +// Boot-time messages below are noisy configuration/driver diagnostics, not +// hardware incidents. Raw dmesg remains in the bundle, but presenting each +// of them as Critical makes the event log unusable. +var dmesgIgnorePatterns = []*regexp.Regexp{ + regexp.MustCompile(`(?i)bridge window .* failed to assign`), + regexp.MustCompile(`(?i)^NVRM: loading NVIDIA .* Kernel Module`), + regexp.MustCompile(`(?i)^NVRM: Persistence mode is deprecated`), + regexp.MustCompile(`(?i)^nvidia: module verification failed:.*tainting kernel`), + regexp.MustCompile(`(?i)^Yama: disabled by default`), + regexp.MustCompile(`(?i)^ERST: .*initialized`), + regexp.MustCompile(`(?i)iommu sva bind failed: -95`), + regexp.MustCompile(`(?i)gpuClearFbhubPoisonIntrForBug`), +} + // collectDmesgErrors runs `dmesg -T` (or `dmesg` without -T on failure) and // returns only lines that match known error/warning patterns. func collectDmesgErrors() []schema.HardwareEventLog { @@ -77,6 +91,9 @@ func parseDmesgErrors(output string) []schema.HardwareEventLog { if !matchesAny(message, dmesgErrorPatterns) { continue } + if matchesAny(message, dmesgIgnorePatterns) { + continue + } severity := dmesgSeverity(message) source := "dmesg" @@ -114,10 +131,8 @@ func matchesAny(s string, patterns []*regexp.Regexp) bool { // "fault" (de-fault), and "undead" would contain "dead". var dmesgSeverityCriticalPatterns = []*regexp.Regexp{ regexp.MustCompile(`(?i)\bpanic\b`), - regexp.MustCompile(`(?i)\baer\b`), regexp.MustCompile(`(?i)\buncorrect`), regexp.MustCompile(`(?i)\bxid\b`), - regexp.MustCompile(`(?i)\bnvrm\b`), regexp.MustCompile(`(?i)\berror\b`), regexp.MustCompile(`(?i)\bfault\b`), regexp.MustCompile(`(?i)\bfail(ed|ure)?\b`), diff --git a/audit/internal/collector/dmesg_events_test.go b/audit/internal/collector/dmesg_events_test.go index ad04de2..80c6284 100644 --- a/audit/internal/collector/dmesg_events_test.go +++ b/audit/internal/collector/dmesg_events_test.go @@ -38,11 +38,6 @@ func TestDmesgSeverity_xidCodes(t *testing.T) { msg: "Yama: disabled by default; enable with sysctl kernel.yama.*", want: statusWarning, }, - { - name: "benign NVRM driver load message still escalates via NVRM keyword", - msg: "NVRM: loading NVIDIA UNIX Open Kernel Module for x86_64 580.159.03", - want: statusCritical, - }, } for _, tt := range tests { @@ -54,3 +49,12 @@ func TestDmesgSeverity_xidCodes(t *testing.T) { }) } } + +func TestParseDmesgErrorsSkipsKnownBootNoise(t *testing.T) { + entries := parseDmesgErrors("[Thu Aug 25 10:00:00 2026] pci 0000:00:02.0: bridge window [mem 0x0000-0x0000] to [bus 01-ff] failed to assign\n" + + "[Thu Aug 25 10:00:01 2026] NVRM: loading NVIDIA UNIX Open Kernel Module for x86_64\n" + + "[Thu Aug 25 10:00:02 2026] blk_update_request: I/O error, dev sda\n") + if len(entries) != 1 || entries[0].Severity == nil || *entries[0].Severity != statusCritical { + t.Fatalf("entries=%#v, want one critical real error", entries) + } +} diff --git a/audit/internal/collector/nic_telemetry.go b/audit/internal/collector/nic_telemetry.go index 33fa481..f08ddad 100644 --- a/audit/internal/collector/nic_telemetry.go +++ b/audit/internal/collector/nic_telemetry.go @@ -6,6 +6,7 @@ import ( "os" "path/filepath" "regexp" + "sort" "strconv" "strings" ) @@ -42,6 +43,7 @@ func enrichPCIeWithNICTelemetry(devs []schema.HardwarePCIeDevice) []schema.Hardw if len(ifaces) == 0 { continue } + sort.Strings(ifaces) iface := ifaces[0] devs[i].MacAddresses = collectInterfaceMACs(ifaces) if devs[i].SerialNumber == nil { @@ -58,11 +60,24 @@ func enrichPCIeWithNICTelemetry(devs []schema.HardwarePCIeDevice) []schema.Hardw } } - if out, err := ethtoolModuleQuery(iface); err == nil { - if injectSFPDOMTelemetry(&devs[i], out) { - enriched++ + for port, portIface := range ifaces { + out, err := ethtoolModuleQuery(portIface) + if err != nil { continue } + legacy := schema.HardwarePCIeDevice{} + if !injectSFPDOMTelemetry(&legacy, out) || legacy.SFPIdentifier == nil { + continue + } + devs[i].SFPModules = append(devs[i].SFPModules, sfpModuleFromLegacy(port, legacy)) + // Keep the v2.10 scalar representation for old Reanimator installs. + if len(devs[i].SFPModules) == 1 { + copyLegacySFPFields(&devs[i], legacy) + } + } + if len(devs[i].SFPModules) > 0 { + enriched++ + continue } if len(devs[i].MacAddresses) > 0 || devs[i].Firmware != nil { enriched++ @@ -72,6 +87,27 @@ func enrichPCIeWithNICTelemetry(devs []schema.HardwarePCIeDevice) []schema.Hardw return devs } +func sfpModuleFromLegacy(port int, dev schema.HardwarePCIeDevice) schema.HardwareSFPModule { + m := schema.HardwareSFPModule{Port: port, Identifier: dev.SFPIdentifier, Connector: dev.SFPConnector, + Vendor: dev.SFPVendor, PartNumber: dev.SFPPartNumber, SerialNumber: dev.SFPSerialNumber, + Revision: dev.SFPRevision, TransceiverType: dev.SFPTransceiverType, + TemperatureC: dev.SFPTemperatureC, TXPowerDBM: dev.SFPTXPowerDBM, RXPowerDBM: dev.SFPRXPowerDBM, + VoltageV: dev.SFPVoltageV, BiasMA: dev.SFPBiasMA} + if dev.SFPWavelengthNM != nil { + v := int(*dev.SFPWavelengthNM + 0.5) + m.WavelengthNM = &v + } + return m +} + +func copyLegacySFPFields(dst *schema.HardwarePCIeDevice, src schema.HardwarePCIeDevice) { + dst.SFPPresent, dst.SFPIdentifier, dst.SFPConnector = src.SFPPresent, src.SFPIdentifier, src.SFPConnector + dst.SFPVendor, dst.SFPPartNumber, dst.SFPSerialNumber = src.SFPVendor, src.SFPPartNumber, src.SFPSerialNumber + dst.SFPRevision, dst.SFPTransceiverType = src.SFPRevision, src.SFPTransceiverType + dst.SFPWavelengthNM, dst.SFPTemperatureC, dst.SFPTXPowerDBM, dst.SFPRXPowerDBM = src.SFPWavelengthNM, src.SFPTemperatureC, src.SFPTXPowerDBM, src.SFPRXPowerDBM + dst.SFPVoltageV, dst.SFPBiasMA = src.SFPVoltageV, src.SFPBiasMA +} + func isNICDevice(dev schema.HardwarePCIeDevice) bool { if dev.DeviceClass == nil { return false @@ -140,6 +176,14 @@ func injectSFPDOMTelemetry(dev *schema.HardwarePCIeDevice, raw string) bool { s := strings.TrimSpace(val) dev.SFPSerialNumber = &s changed = true + case key == "vendor rev": + s := strings.TrimSpace(val) + dev.SFPRevision = &s + changed = true + case key == "transceiver type": + s := strings.TrimSpace(val) + dev.SFPTransceiverType = &s + changed = true case strings.Contains(key, "laser wavelength"): if f, ok := firstFloat(val); ok { dev.SFPWavelengthNM = &f diff --git a/audit/internal/collector/nic_telemetry_test.go b/audit/internal/collector/nic_telemetry_test.go index 0fb8148..12830b3 100644 --- a/audit/internal/collector/nic_telemetry_test.go +++ b/audit/internal/collector/nic_telemetry_test.go @@ -33,6 +33,14 @@ func TestParseSFPDOM(t *testing.T) { } } +func TestSFPModuleFromLegacyUsesContractShape(t *testing.T) { + identifier, wavelength := "QSFP-DD", 1310.4 + m := sfpModuleFromLegacy(1, schema.HardwarePCIeDevice{SFPIdentifier: &identifier, SFPWavelengthNM: &wavelength}) + if m.Port != 1 || m.Identifier == nil || *m.Identifier != "QSFP-DD" || m.WavelengthNM == nil || *m.WavelengthNM != 1310 { + t.Fatalf("module=%#v", m) + } +} + func TestParseLSPCIDetailSerial(t *testing.T) { raw := ` 05:00.0 Ethernet controller: Mellanox Technologies MT28908 Family [ConnectX-6] diff --git a/audit/internal/collector/psu.go b/audit/internal/collector/psu.go index eee0b90..c08582e 100644 --- a/audit/internal/collector/psu.go +++ b/audit/internal/collector/psu.go @@ -39,7 +39,7 @@ func collectPSUs(manufacturer string) []schema.HardwarePowerSupply { if len(psus) == 0 { psus = synthesizePSUsFromSDR(sdrData) } else { - mergePSUSDR(psus, sdrData) + psus = mergePSUSDR(psus, sdrData) } } else if len(psus) == 0 { slog.Info("psu: ipmitool unavailable, skipping", "err", err) @@ -237,21 +237,22 @@ var psuSlotPatterns = []*regexp.Regexp{ // MSI/underscore style: PSU1_POWER_IN, PSU2_POWER_OUT — underscore is \w so \b // does not fire after the digit; match explicitly with underscore terminator. regexp.MustCompile(`(?i)\bpsu([0-9]+)_`), - regexp.MustCompile(`(?i)\bpsu?\s*([0-9]+)\b`), // PSU1, PS1, ps 2 - regexp.MustCompile(`(?i)\bps\s*([0-9]+)\b`), // PS 6, PS6 - regexp.MustCompile(`(?i)\bpws\s*([0-9]+)\b`), // PWS1 + regexp.MustCompile(`(?i)\bpsu?\s*([0-9]+)\b`), // PSU1, PS1, ps 2 + regexp.MustCompile(`(?i)\bps\s*([0-9]+)\b`), // PS 6, PS6 + regexp.MustCompile(`(?i)\bpws\s*([0-9]+)\b`), // PWS1 regexp.MustCompile(`(?i)\bpower\s*supply(?:\s*bay)?\s*([0-9]+)\b`), // Power Supply 1, Power Supply Bay 3 - regexp.MustCompile(`(?i)\bbay\s*([0-9]+)\b`), // Bay 1 + regexp.MustCompile(`(?i)\bbay\s*([0-9]+)\b`), // Bay 1 // Fallback for xFusion-style generic numbered PSU sensors (Power1, Power2, …). // Must be last: "power supply N" is already caught by the pattern above. regexp.MustCompile(`(?i)\bpower([0-9]+)\b`), } // psuInputPowerKeywords matches AC-input power sensor names across vendors: -// MSI: PSU1_POWER_IN, PSU1_PIN -// MLT: PSU1_PIN -// xFusion: (matched via default fallback — no explicit keyword) -// HPE: PS1 Input Power, PS1 Input Watts +// +// MSI: PSU1_POWER_IN, PSU1_PIN +// MLT: PSU1_PIN +// xFusion: (matched via default fallback — no explicit keyword) +// HPE: PS1 Input Power, PS1 Input Watts func isPSUInputPower(name string) bool { return strings.Contains(name, "input power") || strings.Contains(name, "input watts") || @@ -262,9 +263,10 @@ func isPSUInputPower(name string) bool { } // isPSUOutputPower matches DC-output power sensor names across vendors: -// MSI: PSU1_POWER_OUT -// MLT: PSU1_POUT -// xFusion: PS1 POut +// +// MSI: PSU1_POWER_OUT +// MLT: PSU1_POUT +// xFusion: PS1 POut func isPSUOutputPower(name string) bool { return strings.Contains(name, "output power") || strings.Contains(name, "output watts") || @@ -410,12 +412,14 @@ func synthesizePSUsFromSDR(sdr map[int]psuSDR) []schema.HardwarePowerSupply { return out } -func mergePSUSDR(psus []schema.HardwarePowerSupply, sdr map[int]psuSDR) { +func mergePSUSDR(psus []schema.HardwarePowerSupply, sdr map[int]psuSDR) []schema.HardwarePowerSupply { + matched := map[int]bool{} for i := range psus { slotIdx, err := strconv.Atoi(derefPSUSlot(psus[i].Slot)) if err != nil { continue } + matched[slotIdx+1] = true entry, ok := sdr[slotIdx+1] if !ok { continue @@ -450,6 +454,15 @@ func mergePSUSDR(psus []schema.HardwarePowerSupply, sdr map[int]psuSDR) { } } } + // FRU can be incomplete (notably on CX270); do not discard a PSU that is + // visible in SDR merely because another PSU was present in FRU. + missing := make(map[int]psuSDR) + for slot, entry := range sdr { + if !matched[slot] { + missing[slot] = entry + } + } + return append(psus, synthesizePSUsFromSDR(missing)...) } func splitSDRFields(line string) []string { diff --git a/audit/internal/collector/psu_sdr_test.go b/audit/internal/collector/psu_sdr_test.go index b24d43e..52dd92e 100644 --- a/audit/internal/collector/psu_sdr_test.go +++ b/audit/internal/collector/psu_sdr_test.go @@ -1,6 +1,9 @@ package collector -import "testing" +import ( + "bee/audit/internal/schema" + "testing" +) func TestParsePSUSDR(t *testing.T) { raw := ` @@ -118,3 +121,15 @@ func TestSynthesizePSUsFromSDR(t *testing.T) { t.Fatalf("life used=%v", got[0].LifeUsedPct) } } + +func TestMergePSUSDRAppendsPSUMissingFromFRU(t *testing.T) { + model := "PSU0" + slot := "0" + got := mergePSUSDR([]schema.HardwarePowerSupply{{Slot: &slot, Model: &model}}, map[int]psuSDR{ + 1: {slot: 1, status: statusOK}, + 2: {slot: 2, status: statusOK}, + }) + if len(got) != 2 || got[1].Slot == nil || *got[1].Slot != "1" { + t.Fatalf("PSUs=%#v, want FRU PSU0 plus synthesized PSU1", got) + } +} diff --git a/audit/internal/collector/raid.go b/audit/internal/collector/raid.go index a460bf5..5dbd6b8 100644 --- a/audit/internal/collector/raid.go +++ b/audit/internal/collector/raid.go @@ -652,21 +652,56 @@ func appendUniqueStorage(base, extra []schema.HardwareStorage) []schema.Hardware if len(extra) == 0 { return base } - seen := map[string]bool{} - for _, d := range base { - seen[storageIdentityKey(d)] = true + seen := map[string]int{} + for i, d := range base { + if key := storageIdentityKey(d); key != "" { + seen[key] = i + } } for _, d := range extra { key := storageIdentityKey(d) - if key == "" || seen[key] { + if key == "" { + continue + } + if idx, ok := seen[key]; ok { + mergeStorageRecord(&base[idx], d) continue } base = append(base, d) - seen[key] = true + seen[key] = len(base) - 1 } return base } +func mergeStorageRecord(dst *schema.HardwareStorage, src schema.HardwareStorage) { + if dst.Model == nil { + dst.Model = src.Model + } + if dst.SizeGB == nil { + dst.SizeGB = src.SizeGB + } + if dst.Interface == nil { + dst.Interface = src.Interface + } + if dst.Firmware == nil { + dst.Firmware = src.Firmware + } + if dst.Slot == nil { + dst.Slot = src.Slot + } + if dst.Manufacturer == nil { + dst.Manufacturer = src.Manufacturer + } + if dst.Present == nil { + dst.Present = src.Present + } + // A RAID controller often reports JBOD as Unknown; never replace a direct + // device health reading with that weaker verdict. + if dst.Status == nil { + dst.Status = src.Status + } +} + func storageIdentityKey(d schema.HardwareStorage) string { if d.SerialNumber != nil && strings.TrimSpace(*d.SerialNumber) != "" { return "sn:" + strings.ToLower(strings.TrimSpace(*d.SerialNumber)) diff --git a/audit/internal/collector/raid_parsers_test.go b/audit/internal/collector/raid_parsers_test.go index 6afd732..367e90d 100644 --- a/audit/internal/collector/raid_parsers_test.go +++ b/audit/internal/collector/raid_parsers_test.go @@ -6,6 +6,16 @@ import ( "testing" ) +func TestAppendUniqueStorageMergesRAIDMetadataForSameDisk(t *testing.T) { + serial, status, model, iface := "SN-1", "OK", "Disk Model", "SAS" + base := []schema.HardwareStorage{{SerialNumber: &serial, HardwareComponentStatus: schema.HardwareComponentStatus{Status: &status}}} + extra := []schema.HardwareStorage{{SerialNumber: &serial, Model: &model, Interface: &iface}} + got := appendUniqueStorage(base, extra) + if len(got) != 1 || got[0].Model == nil || *got[0].Model != model || got[0].Interface == nil || *got[0].Interface != iface { + t.Fatalf("merged storage=%#v", got) + } +} + func TestParseSASIrcuControllerIDs(t *testing.T) { raw := `LSI Corporation SAS2 IR Configuration Utility. Adapter List diff --git a/audit/internal/platform/nvidia_config_check.go b/audit/internal/platform/nvidia_config_check.go index e2c91a4..d2cfc80 100644 --- a/audit/internal/platform/nvidia_config_check.go +++ b/audit/internal/platform/nvidia_config_check.go @@ -43,6 +43,9 @@ func (s *System) RunNvidiaConfigCheckPack(ctx context.Context, baseDir string, l if settings, err := s.ListNvidiaGPUSettings(); err != nil { status.Notes = append(status.Notes, "nvidia-smi GPU settings unavailable (no driver, or no GPU present): "+err.Error()) } else { + if len(settings) == 0 { + status.NoGPUs = true + } for _, g := range settings { f := NvidiaGPUConfigFinding{ Index: g.Index, Name: g.Name, @@ -127,6 +130,7 @@ type NvidiaConfigCheckStatus struct { GPUs []NvidiaGPUConfigFinding `json:"gpus,omitempty"` NVLinkPairs []NvidiaNVLinkPairFinding `json:"nvlink_pairs,omitempty"` + NoGPUs bool `json:"no_gpus"` // Confidential Computing readiness — informational only, does not gate // overall_status: an unconfigured/NOT_READY CC state is a @@ -466,7 +470,10 @@ func renderNvidiaConfigCheckSummary(status NvidiaConfigCheckStatus) string { fmt.Fprintf(&b, "cc_state=%s\n", status.CCState) fmt.Fprintf(&b, "cpu_cc_capability=%s\n", status.CPUCCCapability) fmt.Fprintf(&b, "gpu_cc_capability=%s\n", status.GPUCCCapability) - if len(status.Warnings) == 0 { + if status.NoGPUs { + fmt.Fprintln(&b, "overall_status=UNSUPPORTED") + fmt.Fprintln(&b, "reason=no_nvidia_gpus_detected") + } else if len(status.Warnings) == 0 { fmt.Fprintln(&b, "overall_status=OK") } else { fmt.Fprintln(&b, "overall_status=FAILED") diff --git a/audit/internal/platform/nvidia_config_check_test.go b/audit/internal/platform/nvidia_config_check_test.go index a0bf6ef..c8cab16 100644 --- a/audit/internal/platform/nvidia_config_check_test.go +++ b/audit/internal/platform/nvidia_config_check_test.go @@ -165,6 +165,10 @@ func TestRenderNvidiaConfigCheckSummaryOverallStatus(t *testing.T) { if got := renderNvidiaConfigCheckSummary(withWarning); !strings.Contains(got, "overall_status=FAILED") { t.Fatalf("status with warnings missing overall_status=FAILED:\n%s", got) } + noGPU := NvidiaConfigCheckStatus{NoGPUs: true} + if got := renderNvidiaConfigCheckSummary(noGPU); !strings.Contains(got, "overall_status=UNSUPPORTED") { + t.Fatalf("no-GPU summary must be unsupported:\n%s", got) + } } // TestRenderNvidiaConfigCheckSummaryIncludesWarningsField guards that a diff --git a/audit/internal/platform/sat.go b/audit/internal/platform/sat.go index 6dfd0f0..f50b280 100644 --- a/audit/internal/platform/sat.go +++ b/audit/internal/platform/sat.go @@ -920,6 +920,12 @@ func (s *System) RunStorageAcceptancePack(ctx context.Context, baseDir string, e runSyncBracketHook(job, "after", logFunc) } status, rc := classifySATResult(job.name, out, err) + // A zero smartctl exit status only proves the command ran. If the + // drive did not return its overall-health verdict, it must not turn + // the storage SAT green. + if job.name == "smartctl-health" && status == "OK" && !hasSMARTOverallHealth(out) { + status = "UNSUPPORTED" + } stats.Add(status) key := filepath.Base(devPath) + "_" + strings.ReplaceAll(job.name, "-", "_") fmt.Fprintf(&summary, "%s_rc=%d\n", key, rc) @@ -1560,6 +1566,11 @@ func classifySATResult(name string, out []byte, err error) (string, int) { return "FAILED", rc } +func hasSMARTOverallHealth(out []byte) bool { + m := smartHealthRE.FindStringSubmatch(string(out)) + return len(m) > 1 && strings.TrimSpace(m[1]) != "" +} + func runSATCommand(verboseLog, name string, cmd []string, logFunc func(string)) ([]byte, error) { start := time.Now().UTC() resolvedCmd, err := resolveSATCommand(cmd) diff --git a/audit/internal/platform/storage_report.go b/audit/internal/platform/storage_report.go index ebc270f..688a1b1 100644 --- a/audit/internal/platform/storage_report.go +++ b/audit/internal/platform/storage_report.go @@ -124,6 +124,7 @@ func writeNVMeReport(b *strings.Builder, outputs map[string][]byte) { writtenBytes: writtenBytes, readBytes: readBytes, capacityBytes: capacityBytes, + healthKnown: true, } writeResourceSection(b, ri) @@ -232,6 +233,7 @@ func writeSATAReport(b *strings.Builder, outputs map[string][]byte) { capacityBytes: capacityBytes, readPercent: 100 - readValue, hasReadPercent: hasReadValue, + healthKnown: !strings.EqualFold(health, "unknown"), } writeResourceSection(b, ri) @@ -341,6 +343,7 @@ const ( type resourceInfo struct { powerOnHours uint64 + healthKnown bool powerCycles uint64 writtenBytes uint64 readBytes uint64 @@ -393,7 +396,10 @@ func writeConclusionSection(b *strings.Builder, r resourceInfo) { writeSectionHeader(b, "Conclusion") var reasons, notes []string - isNew := true + isNew := r.healthKnown + if !r.healthKnown { + notes = append(notes, "SMART overall health unavailable — disk cannot be accepted as NEW") + } if r.capacityBytes > 0 { writtenFrac := float64(r.writtenBytes) / float64(r.capacityBytes) @@ -424,7 +430,9 @@ func writeConclusionSection(b *strings.Builder, r resourceInfo) { reasons = append(reasons, fmt.Sprintf("power cycles %s", formatUint(r.powerCycles))) } - if isNew { + if !r.healthKnown { + writeField(b, "Disk Condition", "UNVERIFIED") + } else if isNew { writeField(b, "Disk Condition", "NEW") } else { writeField(b, "Disk Condition", "USED") diff --git a/audit/internal/platform/storage_report_test.go b/audit/internal/platform/storage_report_test.go index 338879f..c5a9403 100644 --- a/audit/internal/platform/storage_report_test.go +++ b/audit/internal/platform/storage_report_test.go @@ -141,6 +141,15 @@ func TestGenerateDiskReportSATA(t *testing.T) { assertContains(t, report, "Power_On_Hours") } +func TestHasSMARTOverallHealth(t *testing.T) { + if !hasSMARTOverallHealth([]byte("SMART overall-health self-assessment test result: PASSED\n")) { + t.Fatal("expected SMART health verdict to be recognized") + } + if hasSMARTOverallHealth([]byte("SMART support is: Available\n")) { + t.Fatal("availability alone must not be accepted as a health verdict") + } +} + func assertContains(t *testing.T, text string, needles ...string) { t.Helper() for _, needle := range needles { diff --git a/audit/internal/schema/hardware.go b/audit/internal/schema/hardware.go index ed7911c..5794ba9 100644 --- a/audit/internal/schema/hardware.go +++ b/audit/internal/schema/hardware.go @@ -178,53 +178,77 @@ type HardwareStorage struct { type HardwarePCIeDevice struct { HardwareComponentStatus - Slot *string `json:"slot,omitempty"` - VendorID *int `json:"vendor_id,omitempty"` - DeviceID *int `json:"device_id,omitempty"` - NUMANode *int `json:"numa_node,omitempty"` - TemperatureC *float64 `json:"temperature_c,omitempty"` - PowerW *float64 `json:"power_w,omitempty"` - LifeRemainingPct *float64 `json:"life_remaining_pct,omitempty"` - LifeUsedPct *float64 `json:"life_used_pct,omitempty"` - ECCCorrectedTotal *int64 `json:"ecc_corrected_total,omitempty"` - ECCUncorrectedTotal *int64 `json:"ecc_uncorrected_total,omitempty"` - HWSlowdown *bool `json:"hw_slowdown,omitempty"` - ResetRequired *bool `json:"reset_required,omitempty"` - RemappedRowsCorrectable *int64 `json:"remapped_rows_correctable,omitempty"` - RemappedRowsUncorrectable *int64 `json:"remapped_rows_uncorrectable,omitempty"` - RemappedRowsPending *bool `json:"remapped_rows_pending,omitempty"` - RemappedRowsFailure *bool `json:"remapped_rows_failure,omitempty"` - BatteryChargePct *float64 `json:"battery_charge_pct,omitempty"` - BatteryHealthPct *float64 `json:"battery_health_pct,omitempty"` - BatteryTemperatureC *float64 `json:"battery_temperature_c,omitempty"` - BatteryVoltageV *float64 `json:"battery_voltage_v,omitempty"` - BatteryReplaceRequired *bool `json:"battery_replace_required,omitempty"` - SFPPresent *bool `json:"sfp_present,omitempty"` - SFPIdentifier *string `json:"sfp_identifier,omitempty"` - SFPConnector *string `json:"sfp_connector,omitempty"` - SFPVendor *string `json:"sfp_vendor,omitempty"` - SFPPartNumber *string `json:"sfp_part_number,omitempty"` - SFPSerialNumber *string `json:"sfp_serial_number,omitempty"` - SFPWavelengthNM *float64 `json:"sfp_wavelength_nm,omitempty"` - SFPTemperatureC *float64 `json:"sfp_temperature_c,omitempty"` - SFPTXPowerDBM *float64 `json:"sfp_tx_power_dbm,omitempty"` - SFPRXPowerDBM *float64 `json:"sfp_rx_power_dbm,omitempty"` - SFPVoltageV *float64 `json:"sfp_voltage_v,omitempty"` - SFPBiasMA *float64 `json:"sfp_bias_ma,omitempty"` - BDF *string `json:"-"` - DeviceClass *string `json:"device_class,omitempty"` - Manufacturer *string `json:"manufacturer,omitempty"` - Model *string `json:"model,omitempty"` - LinkWidth *int `json:"link_width,omitempty"` - LinkSpeed *string `json:"link_speed,omitempty"` - MaxLinkWidth *int `json:"max_link_width,omitempty"` - MaxLinkSpeed *string `json:"max_link_speed,omitempty"` - SerialNumber *string `json:"serial_number,omitempty"` - Firmware *string `json:"firmware,omitempty"` - MacAddresses []string `json:"mac_addresses,omitempty"` - Present *bool `json:"present,omitempty"` - IOMMUGroup *int `json:"iommu_group,omitempty"` - Telemetry map[string]any `json:"-"` + Slot *string `json:"slot,omitempty"` + VendorID *int `json:"vendor_id,omitempty"` + DeviceID *int `json:"device_id,omitempty"` + NUMANode *int `json:"numa_node,omitempty"` + TemperatureC *float64 `json:"temperature_c,omitempty"` + PowerW *float64 `json:"power_w,omitempty"` + LifeRemainingPct *float64 `json:"life_remaining_pct,omitempty"` + LifeUsedPct *float64 `json:"life_used_pct,omitempty"` + ECCCorrectedTotal *int64 `json:"ecc_corrected_total,omitempty"` + ECCUncorrectedTotal *int64 `json:"ecc_uncorrected_total,omitempty"` + HWSlowdown *bool `json:"hw_slowdown,omitempty"` + ResetRequired *bool `json:"reset_required,omitempty"` + RemappedRowsCorrectable *int64 `json:"remapped_rows_correctable,omitempty"` + RemappedRowsUncorrectable *int64 `json:"remapped_rows_uncorrectable,omitempty"` + RemappedRowsPending *bool `json:"remapped_rows_pending,omitempty"` + RemappedRowsFailure *bool `json:"remapped_rows_failure,omitempty"` + BatteryChargePct *float64 `json:"battery_charge_pct,omitempty"` + BatteryHealthPct *float64 `json:"battery_health_pct,omitempty"` + BatteryTemperatureC *float64 `json:"battery_temperature_c,omitempty"` + BatteryVoltageV *float64 `json:"battery_voltage_v,omitempty"` + BatteryReplaceRequired *bool `json:"battery_replace_required,omitempty"` + SFPPresent *bool `json:"sfp_present,omitempty"` + SFPIdentifier *string `json:"sfp_identifier,omitempty"` + SFPConnector *string `json:"sfp_connector,omitempty"` + SFPVendor *string `json:"sfp_vendor,omitempty"` + SFPPartNumber *string `json:"sfp_part_number,omitempty"` + SFPSerialNumber *string `json:"sfp_serial_number,omitempty"` + SFPRevision *string `json:"sfp_revision,omitempty"` + SFPTransceiverType *string `json:"sfp_transceiver_type,omitempty"` + SFPWavelengthNM *float64 `json:"sfp_wavelength_nm,omitempty"` + SFPTemperatureC *float64 `json:"sfp_temperature_c,omitempty"` + SFPTXPowerDBM *float64 `json:"sfp_tx_power_dbm,omitempty"` + SFPRXPowerDBM *float64 `json:"sfp_rx_power_dbm,omitempty"` + SFPVoltageV *float64 `json:"sfp_voltage_v,omitempty"` + SFPBiasMA *float64 `json:"sfp_bias_ma,omitempty"` + // SFPModules is the per-port transceiver inventory. The scalar SFP* + // fields above are retained for compatibility and mirror port 0 only. + SFPModules []HardwareSFPModule `json:"sfp_modules,omitempty"` + BDF *string `json:"-"` + DeviceClass *string `json:"device_class,omitempty"` + Manufacturer *string `json:"manufacturer,omitempty"` + Model *string `json:"model,omitempty"` + LinkWidth *int `json:"link_width,omitempty"` + LinkSpeed *string `json:"link_speed,omitempty"` + MaxLinkWidth *int `json:"max_link_width,omitempty"` + MaxLinkSpeed *string `json:"max_link_speed,omitempty"` + SerialNumber *string `json:"serial_number,omitempty"` + Firmware *string `json:"firmware,omitempty"` + MacAddresses []string `json:"mac_addresses,omitempty"` + Present *bool `json:"present,omitempty"` + IOMMUGroup *int `json:"iommu_group,omitempty"` + Telemetry map[string]any `json:"-"` +} + +// HardwareSFPModule is one optical/electrical transceiver attached to a NIC +// port. Port is the zero-based port number within its PCIe device. +type HardwareSFPModule struct { + Port int `json:"port"` + Identifier *string `json:"identifier,omitempty"` + Connector *string `json:"connector,omitempty"` + Vendor *string `json:"vendor,omitempty"` + PartNumber *string `json:"part_number,omitempty"` + SerialNumber *string `json:"serial_number,omitempty"` + Revision *string `json:"revision,omitempty"` + TransceiverType *string `json:"transceiver_type,omitempty"` + WavelengthNM *int `json:"wavelength_nm,omitempty"` + TemperatureC *float64 `json:"temperature_c,omitempty"` + TXPowerDBM *float64 `json:"tx_power_dbm,omitempty"` + RXPowerDBM *float64 `json:"rx_power_dbm,omitempty"` + VoltageV *float64 `json:"voltage_v,omitempty"` + BiasMA *float64 `json:"bias_ma,omitempty"` } type HardwarePowerSupply struct { diff --git a/iso/builder/build.sh b/iso/builder/build.sh index e2a424d..9328925 100755 --- a/iso/builder/build.sh +++ b/iso/builder/build.sh @@ -9,6 +9,7 @@ if [ "${BEE_CONTAINER_BUILD:-0}" != "1" ]; then fi REPO_ROOT="$(cd "$(dirname "$0")/../.." && pwd)" +PROJECT_BUILD_COMMIT="$(git -C "${REPO_ROOT}" rev-parse --short=12 HEAD 2>/dev/null || echo unknown)" BUILDER_DIR="${REPO_ROOT}/iso/builder" OVERLAY_DIR="${REPO_ROOT}/iso/overlay" DIST_DIR="${REPO_ROOT}/dist" @@ -1364,7 +1365,7 @@ if [ "$NEED_BUILD" = "1" ]; then "cd '${REPO_ROOT}/audit' && \ env GOOS=linux GOARCH=amd64 CGO_ENABLED=0 \ go build \ - -ldflags '-s -w -X main.Version=${PROJECT_VERSION_EFFECTIVE}' \ + -ldflags '-s -w -X main.Version=${PROJECT_VERSION_EFFECTIVE} -X main.BuildCommit=${PROJECT_BUILD_COMMIT}' \ -o '${BEE_BIN}' \ ./cmd/bee" echo "binary: $BEE_BIN" diff --git a/scripts/deploy.sh b/scripts/deploy.sh index 9f8a5b1..86f59e2 100755 --- a/scripts/deploy.sh +++ b/scripts/deploy.sh @@ -42,8 +42,9 @@ echo "==> Сборка бинарника..." ( cd audit VERSION=$(sh ./scripts/resolve-version.sh 2>/dev/null || echo "dev") + BUILD_COMMIT=$(git -C .. rev-parse --short=12 HEAD 2>/dev/null || echo "unknown") CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \ - go build -ldflags "-X main.Version=${VERSION}" -o bee ./cmd/bee + go build -ldflags "-X main.Version=${VERSION} -X main.BuildCommit=${BUILD_COMMIT}" -o bee ./cmd/bee ) echo " OK: $(ls -lh "${LOCAL_BIN}" | awk '{print $5, $9}')"