ed97232598
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.
145 lines
3.8 KiB
Go
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 }
|