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 }