Files
bee/audit/internal/platform/storage_report.go
Michael Chus 5b98005d5d storage report: add new-vs-used disk verdict, human-readable data units, collect smartctl -i
Disk report now ends with a Conclusion section judging a drive NEW/USED
against loose thresholds (<110% capacity written, <210% read, <7d
uptime, <30 power cycles), listing which ones tripped. Data
Written/Read in the Usage section now scale to TB/PB via
formatBytesHuman instead of always printing raw GB. storageSATCommands
now runs smartctl with -i so SATA/SAS reports get Model/Serial/
Firmware/Capacity, which the Conclusion needs to evaluate the
write/read criteria (previously only -H -A was collected).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-02 12:14:20 +03:00

549 lines
16 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package platform
import (
"bee/audit/internal/collector"
"encoding/json"
"fmt"
"math"
"path/filepath"
"regexp"
"strconv"
"strings"
"time"
)
// GenerateDiskReportText builds a human-readable text report for one storage
// device from the raw command outputs collected during storage SAT.
//
// outputs keys match satJob.name: "nvme-id-ctrl", "nvme-smart-log",
// "smartctl-health", "smartctl-self-test-short".
func GenerateDiskReportText(index int, devPath string, outputs map[string][]byte, ts time.Time) string {
var b strings.Builder
devName := filepath.Base(devPath)
line := strings.Repeat("=", 80)
b.WriteString(line + "\n")
fmt.Fprintf(&b, "Disk %-3d %s\n", index, devPath)
b.WriteString(line + "\n")
isNVMe := strings.Contains(devName, "nvme")
if isNVMe {
writeNVMeReport(&b, outputs)
} else {
writeSATAReport(&b, outputs)
}
b.WriteString("\n")
fmt.Fprintf(&b, "Collected : %s\n", ts.UTC().Format("2006-01-02 15:04:05 UTC"))
b.WriteString(line + "\n")
return b.String()
}
// ── NVMe ─────────────────────────────────────────────────────────────────────
func writeNVMeReport(b *strings.Builder, outputs map[string][]byte) {
// id-ctrl
var ctrl collector.NVMeIDCtrl
if data := outputs["nvme-id-ctrl"]; len(data) > 0 {
_ = json.Unmarshal(data, &ctrl)
}
model := strings.TrimSpace(ctrl.ModelNumber)
serial := strings.TrimSpace(ctrl.SerialNumber)
firmware := strings.TrimSpace(ctrl.FirmwareRev)
capacityGB := ""
if ctrl.TotalCapacity > 0 {
capacityGB = formatCapacityGB(uint64(ctrl.TotalCapacity))
} else if ctrl.NVMCapacity > 0 {
capacityGB = formatCapacityGB(uint64(ctrl.NVMCapacity))
}
writeField(b, "Model", model)
writeField(b, "Serial", serial)
writeField(b, "Firmware", firmware)
if capacityGB != "" {
writeField(b, "Capacity", capacityGB)
}
// smart-log
data := outputs["nvme-smart-log"]
if len(data) == 0 {
b.WriteString("\n(no SMART data)\n")
return
}
var sl collector.NVMeSmartLog
if err := json.Unmarshal(data, &sl); err != nil {
fmt.Fprintf(b, "\n(SMART parse error: %v)\n", err)
return
}
tempC := int(sl.Temperature) - 273
if tempC < 0 {
tempC = 0
}
critWarnStr := "OK"
if sl.CriticalWarning != 0 {
critWarnStr = fmt.Sprintf("0x%02X", sl.CriticalWarning)
}
poh := uint64(sl.PowerOnHours)
pc := uint64(sl.PowerCycles)
us := uint64(sl.UnsafeShutdowns)
me := uint64(sl.MediaErrors)
nel := uint64(sl.NumErrLogEntries)
// data_units are in 1000 × 512-byte sectors = 512,000 bytes each
readBytes := uint64(sl.DataUnitsRead) * 512000
writtenBytes := uint64(sl.DataUnitsWritten) * 512000
writeSectionHeader(b, "Health")
writeField(b, "Temperature", fmt.Sprintf("%d °C", tempC))
writeField(b, "Critical Warning", critWarnStr)
writeField(b, "Percentage Used", fmt.Sprintf("%d %%", sl.PercentageUsed))
writeField(b, "Available Spare", fmt.Sprintf("%d %% (threshold: %d %%)", sl.AvailableSpare, sl.SpareThreshold))
writeSectionHeader(b, "Usage")
writeField(b, "Power On Hours", fmt.Sprintf("%s h", formatUint(poh)))
writeField(b, "Power Cycles", formatUint(pc))
writeField(b, "Unsafe Shutdowns", formatUint(us))
writeField(b, "Data Written", formatBytesHuman(float64(writtenBytes)))
writeField(b, "Data Read", formatBytesHuman(float64(readBytes)))
writeSectionHeader(b, "Errors")
writeField(b, "Media Errors", formatUint(me))
writeField(b, "Error Log Entries", formatUint(nel))
capacityBytes := uint64(ctrl.TotalCapacity)
if capacityBytes == 0 {
capacityBytes = uint64(ctrl.NVMCapacity)
}
ri := resourceInfo{
powerOnHours: poh,
powerCycles: pc,
writtenBytes: writtenBytes,
readBytes: readBytes,
capacityBytes: capacityBytes,
}
writeResourceSection(b, ri)
if selfTest := outputs["nvme-device-self-test"]; len(selfTest) > 0 {
writeSectionHeader(b, "Self-Test")
result := parseSelfTestResult(string(selfTest))
writeField(b, "Result", result)
}
writeConclusionSection(b, ri)
}
// ── SATA / SAS (smartctl) ────────────────────────────────────────────────────
var (
smartHealthRE = regexp.MustCompile(`(?i)SMART overall-health self-assessment test result:\s*(\S+)`)
smartAttrLineRE = regexp.MustCompile(
`^\s*(\d{1,3})\s+(\S+)\s+0x[0-9a-fA-F]+\s+(\d{1,3})\s+(\d{1,3})\s+(\d{1,3})\s+\S+\s+\S+\s+\S+\s+(.+?)\s*$`,
)
smartModelRE = regexp.MustCompile(`(?im)^Device Model:\s*(.+)$`)
smartSerialRE = regexp.MustCompile(`(?im)^Serial Number:\s*(.+)$`)
smartFirmwareRE = regexp.MustCompile(`(?im)^Firmware Version:\s*(.+)$`)
smartCapacityRE = regexp.MustCompile(`(?im)^User Capacity:\s*(.+)$`)
)
type smartAttr struct {
ID int
Name string
Value int
Worst int
Threshold int
Raw string
}
func writeSATAReport(b *strings.Builder, outputs map[string][]byte) {
data := outputs["smartctl-health"]
if len(data) == 0 {
b.WriteString("\n(no SMART data)\n")
return
}
text := string(data)
// Identity
if m := smartModelRE.FindStringSubmatch(text); m != nil {
writeField(b, "Model", strings.TrimSpace(m[1]))
}
if m := smartSerialRE.FindStringSubmatch(text); m != nil {
writeField(b, "Serial", strings.TrimSpace(m[1]))
}
if m := smartFirmwareRE.FindStringSubmatch(text); m != nil {
writeField(b, "Firmware", strings.TrimSpace(m[1]))
}
var capacityBytes uint64
if m := smartCapacityRE.FindStringSubmatch(text); m != nil {
cap := strings.TrimSpace(m[1])
capacityBytes = parseLeadingUint(cap)
// trim everything after "[" if present (e.g. "500,107,862,016 bytes [500 GB]")
if idx := strings.Index(cap, "["); idx > 0 {
cap = strings.TrimSpace(cap[idx+1:])
cap = strings.TrimSuffix(cap, "]")
}
writeField(b, "Capacity", cap)
}
writeSectionHeader(b, "Health")
health := "unknown"
if m := smartHealthRE.FindStringSubmatch(text); m != nil {
health = strings.TrimSpace(m[1])
}
writeField(b, "SMART Overall Health", health)
attrs := parseSMARTAttrs(text)
if len(attrs) > 0 {
writeSectionHeader(b, "SMART Attributes")
fmt.Fprintf(b, " %-4s %-32s %5s %5s %5s %s\n", "ID", "Attribute", "Value", "Worst", "Thresh", "Raw")
b.WriteString(" " + strings.Repeat("-", 72) + "\n")
for _, a := range attrs {
fmt.Fprintf(b, " %-4d %-32s %5d %5d %5d %s\n",
a.ID, a.Name, a.Value, a.Worst, a.Threshold, a.Raw)
}
}
var poh, pc, writtenLBAs, readLBAs uint64
var readValue int
hasReadValue := false
for _, a := range attrs {
switch a.ID {
case 9: // Power_On_Hours
poh = parseLeadingUint(a.Raw)
case 12: // Power_Cycle_Count
pc = parseLeadingUint(a.Raw)
case 241: // Total_LBAs_Written
writtenLBAs = parseLeadingUint(a.Raw)
case 242: // Total_LBAs_Read
readLBAs = parseLeadingUint(a.Raw)
readValue = a.Value
hasReadValue = true
}
}
const sataSectorBytes = 512
ri := resourceInfo{
powerOnHours: poh,
powerCycles: pc,
writtenBytes: writtenLBAs * sataSectorBytes,
readBytes: readLBAs * sataSectorBytes,
capacityBytes: capacityBytes,
readPercent: 100 - readValue,
hasReadPercent: hasReadValue,
}
writeResourceSection(b, ri)
selfTest := outputs["smartctl-self-test-status"]
if len(selfTest) == 0 {
selfTest = outputs["smartctl-self-test-short"]
}
if len(selfTest) > 0 {
writeSectionHeader(b, "Self-Test")
result := parseSelfTestResult(string(selfTest))
writeField(b, "Result", result)
}
writeConclusionSection(b, ri)
}
func parseSMARTAttrs(text string) []smartAttr {
var attrs []smartAttr
inTable := false
for _, line := range strings.Split(text, "\n") {
if strings.Contains(line, "ATTRIBUTE_NAME") {
inTable = true
continue
}
if !inTable {
continue
}
m := smartAttrLineRE.FindStringSubmatch(line)
if m == nil {
if strings.TrimSpace(line) == "" {
inTable = false
}
continue
}
id, _ := strconv.Atoi(m[1])
val, _ := strconv.Atoi(m[3])
worst, _ := strconv.Atoi(m[4])
thresh, _ := strconv.Atoi(m[5])
attrs = append(attrs, smartAttr{
ID: id,
Name: m[2],
Value: val,
Worst: worst,
Threshold: thresh,
Raw: strings.TrimSpace(m[6]),
})
}
return attrs
}
// parseSelfTestResult extracts a one-line summary from nvme device-self-test,
// smartctl -a (post-completion status), or smartctl -t short (launch ack) output.
func parseSelfTestResult(text string) string {
text = strings.TrimSpace(text)
if text == "" {
return "no output"
}
lines := strings.Split(text, "\n")
// smartctl -a: "Self-test execution status: ( 0)\n\tThe previous
// self-test routine completed\n\twithout error ..." — the description
// wraps onto following indented, colon-free continuation lines.
for i, line := range lines {
if strings.Contains(strings.ToLower(line), "self-test execution status") {
parts := []string{strings.TrimSpace(line)}
for j := i + 1; j < len(lines) && j < i+4; j++ {
cont := strings.TrimSpace(lines[j])
if cont == "" || strings.Contains(cont, ":") {
break
}
parts = append(parts, cont)
}
return strings.Join(parts, " ")
}
}
// nvme device-self-test: look for "Short Device Self-Test Status : 0x0" or similar
for _, line := range lines {
l := strings.ToLower(line)
if strings.Contains(l, "self-test status") || strings.Contains(l, "self test status") {
return strings.TrimSpace(line)
}
}
// smartctl -t short: "Testing has begun" or "Short BGST started"
for _, line := range lines {
l := strings.ToLower(line)
if strings.Contains(l, "testing has begun") || strings.Contains(l, "started") || strings.Contains(l, "complete") {
return strings.TrimSpace(line)
}
}
// fallback: last non-empty line
for i := len(lines) - 1; i >= 0; i-- {
if s := strings.TrimSpace(lines[i]); s != "" {
return s
}
}
return "done"
}
// ── Resource (pseudographic usage bars) ────────────────────────────────────────
// designLifeYears/dwpd model the drive's rated endurance: 1 drive-write-per-day
// for 5 years, the baseline enterprise endurance spec used when the vendor's
// own TBW/DWPD rating isn't available from SMART/NVMe data.
const (
designLifeYears = 5
dwpd = 1.0
)
type resourceInfo struct {
powerOnHours uint64
powerCycles uint64
writtenBytes uint64
readBytes uint64
capacityBytes uint64
readPercent int // only meaningful when hasReadPercent
hasReadPercent bool // true when the source SMART attribute exposes a normalized read-wear value
}
func writeResourceSection(b *strings.Builder, r resourceInfo) {
writeSectionHeader(b, "Resource")
const maxLifeHours = designLifeYears * 365 * 24
upFrac := float64(r.powerOnHours) / float64(maxLifeHours)
fmt.Fprintf(b, " %-9s %s %s / %s (%s)\n",
"Uptime", progressBar(upFrac, 24), formatHoursHuman(r.powerOnHours), formatHoursHuman(maxLifeHours), formatPercent(upFrac*100))
if r.capacityBytes > 0 {
maxWritten := float64(r.capacityBytes) * dwpd * designLifeYears * 365
wFrac := float64(r.writtenBytes) / maxWritten
fmt.Fprintf(b, " %-9s %s %s / %s (%s, %g DWPD×%dy)\n",
"Written", progressBar(wFrac, 24), formatBytesHuman(float64(r.writtenBytes)), formatBytesHuman(maxWritten), formatPercent(wFrac*100), dwpd, designLifeYears)
} else {
fmt.Fprintf(b, " %-9s %s\n", "Written", formatBytesHuman(float64(r.writtenBytes)))
}
if r.hasReadPercent {
fmt.Fprintf(b, " %-9s %s %s (%d%%)\n",
"Read", progressBar(float64(r.readPercent)/100, 24), formatBytesHuman(float64(r.readBytes)), r.readPercent)
} else {
fmt.Fprintf(b, " %-9s %s\n", "Read", formatBytesHuman(float64(r.readBytes)))
}
}
// ── Conclusion (new-vs-used verdict) ────────────────────────────────────────
// Thresholds for treating a drive as "new": less than one full drive-write
// (110% of capacity, headroom for provisioning/overprovisioning rounding),
// less than a bit over two full drive-reads (210% of capacity), under a
// week of power-on time, and under 30 power cycles. Any one violation is
// enough to call the drive used — these are deliberately loose bounds, not
// a wear/endurance judgment (see -- Resource -- for that).
const (
newDiskMaxWrittenFrac = 1.10
newDiskMaxReadFrac = 2.10
newDiskMaxUptimeHours = 7 * 24
newDiskMaxPowerCycles = 30
)
func writeConclusionSection(b *strings.Builder, r resourceInfo) {
writeSectionHeader(b, "Conclusion")
var reasons, notes []string
isNew := true
if r.capacityBytes > 0 {
writtenFrac := float64(r.writtenBytes) / float64(r.capacityBytes)
readFrac := float64(r.readBytes) / float64(r.capacityBytes)
if writtenFrac >= newDiskMaxWrittenFrac {
isNew = false
reasons = append(reasons, fmt.Sprintf(
"data written %s (%s of capacity)",
formatBytesHuman(float64(r.writtenBytes)), formatPercent(writtenFrac*100)))
}
if readFrac >= newDiskMaxReadFrac {
isNew = false
reasons = append(reasons, fmt.Sprintf(
"data read %s (%s of capacity)",
formatBytesHuman(float64(r.readBytes)), formatPercent(readFrac*100)))
}
} else {
notes = append(notes, "capacity unknown — write/read criteria not evaluated")
}
if r.powerOnHours >= newDiskMaxUptimeHours {
isNew = false
reasons = append(reasons, fmt.Sprintf("uptime %s", formatHoursHuman(r.powerOnHours)))
}
if r.powerCycles >= newDiskMaxPowerCycles {
isNew = false
reasons = append(reasons, fmt.Sprintf("power cycles %s", formatUint(r.powerCycles)))
}
if isNew {
writeField(b, "Disk Condition", "NEW")
} else {
writeField(b, "Disk Condition", "USED")
b.WriteString(" Reason:\n")
for _, reason := range reasons {
fmt.Fprintf(b, " - %s\n", reason)
}
}
for _, note := range notes {
fmt.Fprintf(b, " Note: %s\n", note)
}
}
// progressBar renders a fixed-width pseudographic bar, e.g. "[######------]".
func progressBar(frac float64, width int) string {
if math.IsNaN(frac) || frac < 0 {
frac = 0
}
if frac > 1 {
frac = 1
}
filled := int(math.Round(frac * float64(width)))
return "[" + strings.Repeat("#", filled) + strings.Repeat("-", width-filled) + "]"
}
// formatBytesHuman renders a decimal (SI) human-readable byte size, e.g. "1.23 TB".
func formatBytesHuman(n float64) string {
units := []string{"B", "KB", "MB", "GB", "TB", "PB"}
i := 0
for n >= 1000 && i < len(units)-1 {
n /= 1000
i++
}
if i == 0 {
return fmt.Sprintf("%.0f %s", n, units[i])
}
return fmt.Sprintf("%.2f %s", n, units[i])
}
// formatHoursHuman renders an hour count as a human-scaled duration (hours,
// days, or years) so uptimes don't show as raw four/five-digit hour counts.
func formatHoursHuman(hours uint64) string {
if hours < 48 {
return fmt.Sprintf("%d h", hours)
}
days := float64(hours) / 24
if days < 365 {
return fmt.Sprintf("%.0f d", days)
}
years := days / 365
if years == math.Trunc(years) {
return fmt.Sprintf("%.0f y", years)
}
return fmt.Sprintf("%.1f y", years)
}
// formatPercent renders a percentage with extra precision below 1% (e.g.
// "0.03%"), where a rounded "0%" would hide any usage at all.
func formatPercent(pct float64) string {
if pct > 0 && pct < 1 {
return fmt.Sprintf("%.2f%%", pct)
}
return fmt.Sprintf("%.0f%%", pct)
}
// parseLeadingUint parses the leading run of digits/commas in s (e.g. from a
// SMART raw value or "500,107,862,016 bytes") into a uint64, ignoring the rest.
func parseLeadingUint(s string) uint64 {
s = strings.TrimSpace(s)
end := 0
for end < len(s) && (s[end] >= '0' && s[end] <= '9' || s[end] == ',') {
end++
}
digits := strings.ReplaceAll(s[:end], ",", "")
n, _ := strconv.ParseUint(digits, 10, 64)
return n
}
// ── Formatting helpers ────────────────────────────────────────────────────────
func writeSectionHeader(b *strings.Builder, title string) {
b.WriteString("\n")
header := "-- " + title + " "
header += strings.Repeat("-", max(0, 76-len(header)))
b.WriteString(header + "\n")
}
func writeField(b *strings.Builder, label, value string) {
fmt.Fprintf(b, " %-20s : %s\n", label, value)
}
func formatCapacityGB(bytes uint64) string {
gb := float64(bytes) / 1e9
if gb >= 1000 {
return fmt.Sprintf("%.2g TB", gb/1000)
}
return fmt.Sprintf("%.0f GB", math.Round(gb))
}
func formatUint(n uint64) string {
if n == 0 {
return "0"
}
s := strconv.FormatUint(n, 10)
// insert thousand separators
var out []byte
for i, c := range s {
if i > 0 && (len(s)-i)%3 == 0 {
out = append(out, ',')
}
out = append(out, byte(c))
}
return string(out)
}
func max(a, b int) int {
if a > b {
return a
}
return b
}