package main import "testing" // ---- JSON path (smartmontools >= 7.0): ATA SSD with a reallocated sector ---- const ataJSON = `{ "model_name": "Micron_1300_MTFDDAK512TDL", "serial_number": "21512A3B4C5D", "firmware_version": "M5MU000", "user_capacity": {"bytes": 512110190592}, "rotation_rate": 0, "smart_status": {"passed": true}, "power_on_time": {"hours": 26280}, "power_cycle_count": 42, "temperature": {"current": 31}, "ata_smart_attributes": {"table": [ {"id": 5, "name": "Reallocated_Sector_Ct", "value": 100, "worst": 100, "thresh": 10, "when_failed": "", "raw": {"value": 8, "string": "8"}}, {"id": 9, "name": "Power_On_Hours", "value": 95, "worst": 95, "thresh": 0, "when_failed": "", "raw": {"value": 26280, "string": "26280"}}, {"id": 199,"name": "UDMA_CRC_Error_Count", "value": 100, "worst": 100, "thresh": 0, "when_failed": "", "raw": {"value": 3, "string": "3"}}, {"id": 202,"name": "Percent_Lifetime_Remain","value": 88, "worst": 88, "thresh": 1, "when_failed": "", "raw": {"value": 12, "string": "12"}} ]} }` func TestParseSmartJSON_ATA(t *testing.T) { d := &Drive{} parseSmartJSON(loadJSON(ataJSON), d) if d.Model != "Micron_1300_MTFDDAK512TDL" { t.Errorf("model = %q", d.Model) } if d.Rotation != "SSD" { t.Errorf("rotation = %q, want SSD", d.Rotation) } if iv(d.Reallocated) != 8 { t.Errorf("reallocated = %v, want 8", d.Reallocated) } if iv(d.UdmaCrc) != 3 { t.Errorf("udma_crc = %v, want 3", d.UdmaCrc) } if iv(d.PowerOnHours) != 26280 { t.Errorf("poh = %v, want 26280", d.PowerOnHours) } if d.WearSrc != "Intel/ID202" || iv(d.WearPctConsumed) != 12 { t.Errorf("wear src=%q consumed=%v, want Intel/ID202 / 12", d.WearSrc, d.WearPctConsumed) } if d.SmartHealth != "PASSED" { t.Errorf("health = %q", d.SmartHealth) } d.HaveSmart = true finalizeDerived(d) // reallocated 8 -> min(40+40,100)=80; udma 3 -> 9; total 89 -> REPLACE_SOON. if d.Recommendation != "REPLACE_SOON" { t.Errorf("rec = %q (score %d), want REPLACE_SOON", d.Recommendation, d.RiskScore) } if iv(d.DefectTotal) != 8 { t.Errorf("defect_total = %v, want 8", d.DefectTotal) } } // ---- Text path (CentOS 6/7, smartmontools 5.x/6.x: no JSON) ---- const ataText = `smartctl 6.5 2016-05-07 r4318 [x86_64-linux] (local build) === START OF INFORMATION SECTION === Device Model: INTEL SSDSC2BB480G6 Serial Number: BTWA12345678480BGN Firmware Version: G2010140 User Capacity: 480,103,981,056 bytes [480 GB] Rotation Rate: Solid State Device === START OF READ SMART DATA SECTION === SMART overall-health self-assessment test result: PASSED ID# ATTRIBUTE_NAME FLAG VALUE WORST THRESH TYPE UPDATED WHEN_FAILED RAW_VALUE 5 Reallocated_Sector_Ct 0x0032 100 100 000 Old_age Always - 0 9 Power_On_Hours 0x0032 100 100 000 Old_age Always - 51000 197 Current_Pending_Sector 0x0012 100 100 000 Old_age Always - 5 233 Media_Wearout_Indicator 0x0032 072 072 000 Old_age Always - 0 ` func TestParseSmartText_ATA(t *testing.T) { d := &Drive{} parseSmartText(ataText, d) if d.Model != "INTEL SSDSC2BB480G6" { t.Errorf("model = %q", d.Model) } if d.SmartHealth != "PASSED" { t.Errorf("health = %q", d.SmartHealth) } if iv(d.Pending) != 5 { t.Errorf("pending = %v, want 5", d.Pending) } if iv(d.PowerOnHours) != 51000 { t.Errorf("poh = %v, want 51000", d.PowerOnHours) } if d.WearSrc != "Generic/ID233" || iv(d.WearPctConsumed) != 28 { t.Errorf("wear src=%q consumed=%v, want Generic/ID233 / 28", d.WearSrc, d.WearPctConsumed) } d.HaveSmart = true finalizeDerived(d) // pending 5 -> min(50+25,100)=75 -> REPLACE_SOON. if d.Recommendation != "REPLACE_SOON" { t.Errorf("rec = %q (score %d), want REPLACE_SOON", d.Recommendation, d.RiskScore) } } // ---- NVMe text path ---- const nvmeText = `smartctl 7.2 2020-12-30 r5155 [x86_64-linux] === START OF INFORMATION SECTION === Model Number: Samsung SSD 980 PRO 1TB Serial Number: S5GXNX0R123456 Firmware Version: 5B2QGXA7 === START OF SMART DATA SECTION === SMART overall-health self-assessment test result: PASSED SMART/Health Information (NVMe Log 0x02) Critical Warning: 0x04 Temperature: 40 Celsius Available Spare: 8% Available Spare Threshold: 10% Percentage Used: 96% Power On Hours: 30,123 Power Cycles: 210 Media and Data Integrity Errors: 0 ` func TestParseSmartText_NVMe(t *testing.T) { d := &Drive{} parseSmartText(nvmeText, d) if d.Rotation != "NVMe" { t.Errorf("rotation = %q, want NVMe", d.Rotation) } if d.NvmeCriticalWarning == nil || *d.NvmeCriticalWarning != 4 { t.Errorf("critical_warning = %v, want 4", d.NvmeCriticalWarning) } if iv(d.NvmeAvailSpare) != 8 || iv(d.NvmeAvailSpareThresh) != 10 { t.Errorf("spare=%v thresh=%v, want 8/10", d.NvmeAvailSpare, d.NvmeAvailSpareThresh) } if iv(d.WearPctConsumed) != 96 { t.Errorf("wear consumed = %v, want 96", d.WearPctConsumed) } d.HaveSmart = true finalizeDerived(d) // crit warning +60, spare<=thresh +40, wear96 +80 -> >=100 REPLACE_NOW. if d.Recommendation != "REPLACE_NOW" { t.Errorf("rec = %q (score %d), want REPLACE_NOW", d.Recommendation, d.RiskScore) } } // ---- NO_DATA: nothing readable, no controller flags ---- func TestNoData(t *testing.T) { d := &Drive{HaveSmart: false} finalizeDerived(d) if d.Recommendation != "NO_DATA" { t.Errorf("rec = %q, want NO_DATA", d.Recommendation) } if d.DefectTotal != nil { t.Errorf("defect_total = %v, want nil (blank)", d.DefectTotal) } } // ---- MegaCLI PDList parsing + controller-driven scoring ---- const megacliText = ` Enclosure Device ID: 64 Slot Number: 3 Device Id: 11 WWN: 5000C500A1B2C3D4 Media Error Count: 369 Other Error Count: 2 Predictive Failure Count: 1 Drive has flagged a S.M.A.R.T alert : Yes Firmware state: Online, Spun Up Inquiry Data: BTWA12345678 INTELSSDSC2BB480G6 G2010140 Drive Temperature: 35C (95.00 F) Enclosure Device ID: 64 Slot Number: 0 Device Id: 8 Firmware state: Online, Spun Up Media Error Count: 0 ` func TestMegacliAndScore(t *testing.T) { drives := parseMegacliPDList(megacliText) if len(drives) != 2 { t.Fatalf("parsed %d drives, want 2", len(drives)) } idx := map[string]ctrlDrive{} for _, cd := range drives { idx[cd.DeviceID] = cd } cd, ok := idx["11"] if !ok { t.Fatal("device 11 not found") } if cd.MediaErr != 369 || cd.Predictive != 1 || !cd.SmartAlert { t.Errorf("dev11 media=%d pred=%d alert=%v", cd.MediaErr, cd.Predictive, cd.SmartAlert) } if cd.Enclosure != "64" || cd.Slot != "3" { t.Errorf("dev11 location %s:%s, want 64:3", cd.Enclosure, cd.Slot) } // Controller-only drive (no smartctl): predictive + alert -> not NO_DATA. d := &Drive{HaveSmart: false} applyController(d, cd) finalizeDerived(d) // predictive +70, alert +50, media 369 +30 = 150 -> REPLACE_NOW. if d.Recommendation != "REPLACE_NOW" { t.Errorf("rec = %q (score %d), want REPLACE_NOW", d.Recommendation, d.RiskScore) } if d.enclosureSlot() != "64:3" { t.Errorf("enclosure_slot = %q", d.enclosureSlot()) } } // ---- MegaCLI Inquiry Data identity fallback (real-world layouts) ---- // applyController's inquiry fallback must anchor serial=first / firmware=last and // treat the (space-containing) remainder as the model. These samples are the real // MegaCLI "Inquiry Data" forms documented alongside the parser; the naive // serial/model/firmware positional split mis-handled them. func TestApplyControllerInquiry(t *testing.T) { cases := []struct { name string inquiry string wantSerial, wantModel, wantFw string }{ { "model with internal space", "50026B727A005DED KINGSTON SEDC400S37480G SAFM02.H", "50026B727A005DED", "KINGSTON SEDC400S37480G", "SAFM02.H", }, { "two tokens: serial + firmware only", "ZRT0CQ55ST12000NM000J-2TY103 SN02", "ZRT0CQ55ST12000NM000J-2TY103", "", "SN02", }, { "clean three tokens", "BTWA12345678 INTELSSDSC2BB480G6 G2010140", "BTWA12345678", "INTELSSDSC2BB480G6", "G2010140", }, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { d := &Drive{} applyController(d, ctrlDrive{Inquiry: c.inquiry}) if d.Serial != c.wantSerial || d.Model != c.wantModel || d.Firmware != c.wantFw { t.Errorf("got serial=%q model=%q fw=%q; want serial=%q model=%q fw=%q", d.Serial, d.Model, d.Firmware, c.wantSerial, c.wantModel, c.wantFw) } }) } } // ---- Output smoke: CSV header + influx line shape ---- func TestOutputShapes(t *testing.T) { d := &Drive{Hostname: "kvm60", Model: "X", Serial: "S1", SmartHealth: "PASSED"} finalizeDerived(d) csv := recordsToCSV([]*Drive{d}) if len(csv) == 0 || csv[:len("collected_at")] != "collected_at" { t.Errorf("csv header malformed: %.40q", csv) } inf := recordsToInflux([]*Drive{d}, 1700000000000000000) if len(inf) < len(influxMeasurement) || inf[:len(influxMeasurement)] != influxMeasurement { t.Errorf("influx line malformed: %.60q", inf) } }