Files
admin ed97232598 v0.95.0: SMART coverage — union-path drives + LVM/dm root + device model
Implements SPIKE-smart-coverage-2026-07-25 fixes B+A (additive; MinAgent unchanged).
Fix B: storage.SmartReader.SMARTForBacking wired into the /disks union path (localapi
Smart seam) so registry/USB drives get a real SMART read (watchdog Known stays
enrich-free). Fix A: smartDeviceFor resolves dm/LVM to the whole disk via
/sys/block/<dm>/slaves (recursive; skips >1-disk); the builtin local dir on the LVM
root gets a SMART-only device from its containing filesystem (never touches
backing/durable_id). SmartSummary.ModelName captured from smartctl. Fix C (-d sat)
stays rejected. Tests + red-proofs (dm multi-disk skip, enrich smartHint, union
routing); Known-path-never-SMARTs asserted.
2026-07-25 08:21:45 +02:00

145 lines
3.8 KiB
Go

package storage
import (
"encoding/json"
"strconv"
"gitea.dooplex.hu/admin/felhom-agent/internal/hub"
)
// smartctlJSON is the lenient subset of `smartctl -a -j` output we read. Pointers detect
// presence so an absent section (e.g. NVMe fields on a SATA disk, or no SMART at all on a
// USB bridge) decodes cleanly to nil and we degrade to UNKNOWN.
type smartctlJSON struct {
ModelName *string `json:"model_name"`
SmartStatus *struct {
Passed bool `json:"passed"`
} `json:"smart_status"`
Temperature *struct {
Current *int `json:"current"`
} `json:"temperature"`
PowerOnTime *struct {
Hours *int `json:"hours"`
} `json:"power_on_time"`
// SATA/ATA attribute table.
ATA *struct {
Table []struct {
ID int `json:"id"`
Raw struct {
Value int64 `json:"value"`
} `json:"raw"`
} `json:"table"`
} `json:"ata_smart_attributes"`
// NVMe health log.
NVMe *struct {
CriticalWarning *int `json:"critical_warning"`
MediaErrors *int64 `json:"media_errors"`
PercentageUsed *int `json:"percentage_used"`
Temperature *int `json:"temperature"`
} `json:"nvme_smart_health_information_log"`
}
// SATA attribute IDs we surface.
const (
ataReallocatedSectorCt = 5
ataCurrentPending = 197
ataOfflineUncorrect = 198
)
// parseSMART maps smartctl JSON to a hub.SmartSummary, handling SATA + NVMe and degrading
// to UNKNOWN when health is not reported. A device populates only its own attribute set.
func parseSMART(raw []byte) hub.SmartSummary {
s := hub.SmartSummary{Health: hub.SmartUnknown}
if len(raw) == 0 {
return s
}
var j smartctlJSON
if err := json.Unmarshal(raw, &j); err != nil {
return s // unparseable → UNKNOWN (never an error to the report)
}
if j.SmartStatus != nil {
if j.SmartStatus.Passed {
s.Health = hub.SmartPassed
} else {
s.Health = hub.SmartFailing
}
}
if j.ModelName != nil && *j.ModelName != "" {
s.ModelName = j.ModelName
}
if j.Temperature != nil && j.Temperature.Current != nil {
s.TemperatureC = j.Temperature.Current
}
if j.PowerOnTime != nil && j.PowerOnTime.Hours != nil {
s.PowerOnHours = j.PowerOnTime.Hours
}
// SATA attributes.
if j.ATA != nil {
for _, a := range j.ATA.Table {
switch a.ID {
case ataReallocatedSectorCt:
s.ReallocatedSectors = intPtr(int(a.Raw.Value))
case ataCurrentPending:
s.PendingSectors = intPtr(int(a.Raw.Value))
case ataOfflineUncorrect:
s.OfflineUncorrectable = intPtr(int(a.Raw.Value))
}
}
}
// NVMe attributes.
if j.NVMe != nil {
s.CriticalWarning = j.NVMe.CriticalWarning
if j.NVMe.MediaErrors != nil {
s.MediaErrors = intPtr(int(*j.NVMe.MediaErrors))
}
s.PercentageUsed = j.NVMe.PercentageUsed
// NVMe reports temperature in its own log when the top-level block is absent.
if s.TemperatureC == nil && j.NVMe.Temperature != nil {
s.TemperatureC = j.NVMe.Temperature
}
}
return s
}
// lvsReport is the lenient subset of `lvs --reportformat json` output.
type lvsReport struct {
Report []struct {
LV []struct {
LVName string `json:"lv_name"`
DataPercent string `json:"data_percent"`
MetadataPercent string `json:"metadata_percent"`
} `json:"lv"`
} `json:"report"`
}
// parseThinPoolMetadata extracts the metadata-used fraction (0..1) from lvs JSON. lvs
// reports percentages as decimal strings (e.g. "10.50"); an empty string means "not a thin
// pool / not applicable" → ok=false.
func parseThinPoolMetadata(raw []byte) (float64, bool) {
if len(raw) == 0 {
return 0, false
}
var r lvsReport
if err := json.Unmarshal(raw, &r); err != nil {
return 0, false
}
for _, rep := range r.Report {
for _, lv := range rep.LV {
if lv.MetadataPercent == "" {
continue
}
pct, err := strconv.ParseFloat(lv.MetadataPercent, 64)
if err != nil {
continue
}
return pct / 100, true
}
}
return 0, false
}
func intPtr(v int) *int { return &v }