mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-23 20:25:56 +00:00
Chart history for disks named by their Unraid serial
A SMART row that reports no serial, smartctl's standby row among them, takes the serial the host's Unraid inventory reports for the same disk: the adapter shows it and the disk's metrics target reads it. The SMART and disk I/O writers keyed the row on its own WWN or host/device key instead, so the chart read a key nothing wrote and the disk's temperature and I/O history never appeared. The I/O writer did the same for an Unraid disk with no SMART reading. HostSMARTDiskMetricID now takes the serial through the adapter's own rule (hostSMARTDiskSerial over matchUnraidDisk), and the I/O writer keys a device without a SMART reading by its Unraid serial before the Proxmox and host/device fallbacks. Source IDs keep the row's own identity, so registry keys and rehydration are unchanged. That rule matches by device path alone, so it now skips a path several SMART rows share. Controller members behind one block device would otherwise all take the device's serial and merge into one disk, and the I/O writer's guard against filing an aggregate counter under a member would stop working. That guard now compares every row on the path by its own identity, standby rows and legacy "sdc [sat]" labels included, so an aggregate counter never lands under one member. The I/O writer also refuses a device that several Unraid rows name. Samples already written under the old keys stay where they are. The chart did not read them while the Unraid row named the disk, and a device-keyed series can hold another disk's samples once device letters move, so they are not folded into the serial's history.
This commit is contained in:
@@ -8791,6 +8791,21 @@ Focused proof lives in `internal/monitoring/physical_disk_roundtrip_test.go`
|
||||
(`TestNestedProxmoxDefaultQEMUSerialsStayPerNode` and
|
||||
`TestTrueNASPlaceholderDiskSerialsStayPerApplianceAndShareOneHistoryKey`).
|
||||
|
||||
### Unraid serials key agent disk history
|
||||
|
||||
`internal/monitoring/monitor_agents.go` changed only so the SMART and disk I/O
|
||||
history writers key a host agent's disk by the serial its resource carries: the
|
||||
SMART row's own, else the one the host's Unraid inventory reports for that disk
|
||||
(`HostSMARTDiskMetricID`, `HostUnraidDeviceMetricID`). While the Unraid row
|
||||
names such a disk, its SMART and I/O history is written under that serial, which
|
||||
its chart already reads, instead of its WWN or host/device key. Agent
|
||||
registration, enrolment, install, update, removal, report identity and disk
|
||||
source IDs are unchanged. Focused proof lives
|
||||
in `internal/monitoring/monitor_host_agents_test.go`
|
||||
(`TestHostDiskIOMetricResourceIDFallbacks`) and
|
||||
`internal/monitoring/issue1595_collection_trust_test.go`
|
||||
(`TestAgentDiskHistoryFollowsTheSerialItsUnraidRowReports`).
|
||||
|
||||
### Windows braced MachineGuid does not abort agent startup
|
||||
|
||||
The Windows unified agent resolves host information through gopsutil's combined
|
||||
|
||||
@@ -4790,6 +4790,20 @@ disk history model, and mock seeding plus live mock ticks in
|
||||
`internal/monitoring/mock_metrics_history.go` must append to that same disk
|
||||
timeline instead of creating a second drawer-only or mock-only disk history
|
||||
path.
|
||||
The SMART-resolved id is the serial the disk resource carries: when a SMART row
|
||||
reports none, `HostSMARTDiskMetricID` takes the one the host's Unraid inventory
|
||||
reports for that disk, as the unified-resources adapter does, unless several
|
||||
SMART rows share the disk's device path, and `hostDiskIOMetricResourceID` keys
|
||||
a device with no non-standby SMART row by its Unraid serial
|
||||
(`HostUnraidDeviceMetricID`, which refuses a device several Unraid rows name)
|
||||
before the linked Proxmox node's disks and the `<host>:<device>` fallback.
|
||||
Controller members sharing a device path keep distinct keys, so the I/O
|
||||
writer's aggregate-device guard, which compares every row on the path by its
|
||||
own identity (standby rows and legacy `sdc [sat]` labels included), files
|
||||
their counter under no member.
|
||||
Proof:
|
||||
`TestAgentDiskHistoryFollowsTheSerialItsUnraidRowReports` and
|
||||
`TestHostDiskIOMetricResourceIDFallbacks`.
|
||||
That same monitoring-owned disk-health boundary also includes shared storage
|
||||
risk assessment in `internal/storagehealth/`. When providers or host agents
|
||||
emit structured storage topology such as Unraid per-disk state, the shared
|
||||
|
||||
@@ -5389,6 +5389,31 @@ disk on a single host. Proof:
|
||||
`TestHostSMARTDiskSourceIDScopesHardwareIdentityToItsHost`,
|
||||
`TestRehydratedAgentDisksKeepTheirLiveSourceIDs`,
|
||||
`TestSplitAgentDisksOnTwoMachinesNeverOverwriteEachOther`.
|
||||
A SMART row that reports no serial, smartctl's standby row among them, takes
|
||||
the serial the host's Unraid inventory reports for the same disk
|
||||
(`hostSMARTDiskSerial` over `matchUnraidDisk`). The adapter shows that serial
|
||||
and the disk's metrics target reads it, so `HostSMARTDiskMetricID` resolves the
|
||||
same serial, and the disk I/O writer keys a device with no SMART reading by its
|
||||
Unraid serial (`HostUnraidDeviceMetricID`) before its Proxmox and host/device
|
||||
fallbacks. The writers used to key such a row on its WWN or host/device key,
|
||||
which the chart did not read, so the disk's SMART temperature and I/O history
|
||||
never appeared. The row's source ID stays its own (`HostSMARTDiskSourceID`
|
||||
reads only the row), so its SMART and Unraid observations still ingest under
|
||||
two keys onto one disk. Samples already written under the old key stay there:
|
||||
the chart did not read that key while the Unraid row named the disk (only
|
||||
Unraid OS reports an Unraid inventory, and it is never a Proxmox node whose
|
||||
disk row could replace the serial), and a device-keyed series can hold another
|
||||
disk's samples once device letters move. The key follows the inventory: while the host
|
||||
reports no Unraid row for the disk, the row's own key is both written and read.
|
||||
A row matches its Unraid row by device path alone, so a row on a path several
|
||||
of the host's SMART rows share, controller members behind one block device,
|
||||
takes no serial from it, and the I/O writer still files that device's counter
|
||||
under no member. The I/O writer's inventory fallback also skips a device
|
||||
several Unraid rows name, while a SMART row keeps the adapter's first match.
|
||||
A row's own serial is kept even when it is a placeholder, as the
|
||||
adapter keeps it, and the Unraid row then stays a disk of its own. Proof:
|
||||
`TestHostSMARTDiskMetricIDTakesTheSerialItsUnraidRowReports`,
|
||||
`TestAgentDiskHistoryFollowsTheSerialItsUnraidRowReports`.
|
||||
That same canonical physical-disk view must also expose source-independent host
|
||||
context. When a disk is API-backed rather than node-backed, typed views should
|
||||
fall back to canonical host identity such as `identity.hostnames` instead of
|
||||
|
||||
@@ -453,6 +453,94 @@ func TestSameSerialAgentDisksOnTwoHostsEachKeepTheirMetricsTarget(t *testing.T)
|
||||
}
|
||||
}
|
||||
|
||||
// An Unraid host reports each array disk twice: as a SMART row and as an
|
||||
// Unraid inventory row. When the SMART row carries no serial, smartctl's
|
||||
// standby row among them, the disk takes the serial its Unraid row reports,
|
||||
// and its metrics target reads that serial. The SMART and disk I/O writers must
|
||||
// file under it too, as must the I/O writer for a disk with no SMART row,
|
||||
// or the disk's temperature and I/O history never chart.
|
||||
func TestAgentDiskHistoryFollowsTheSerialItsUnraidRowReports(t *testing.T) {
|
||||
storeConfig := metrics.DefaultConfig(t.TempDir())
|
||||
storeConfig.FlushInterval = time.Hour
|
||||
store, err := metrics.NewStore(storeConfig)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = store.Close() })
|
||||
m := &Monitor{metricsStore: store, rateTracker: NewRateTracker()}
|
||||
|
||||
now := time.Now().UTC().Truncate(time.Second)
|
||||
collected := func() *diskinventory.CollectionStatus {
|
||||
return &diskinventory.CollectionStatus{Temperature: diskinventory.Available("smartctl")}
|
||||
}
|
||||
host := models.Host{ID: "host-tower", Hostname: "tower", MachineID: "machine-tower", Status: "online", LastSeen: now}
|
||||
host.Sensors.SMART = []models.HostDiskSMART{
|
||||
{Device: "sdb", Temperature: 40, Collection: collected()},
|
||||
{Device: "/dev/sdc", WWN: "0x5000c500aaaa0001", Temperature: 41, Collection: collected()},
|
||||
{Device: "sdd", Standby: true, Collection: &diskinventory.CollectionStatus{
|
||||
Temperature: diskinventory.Unavailable("smartctl", "disk is in standby")}},
|
||||
}
|
||||
host.Unraid = &models.HostUnraidStorage{ArrayStarted: true, Disks: []models.HostUnraidDisk{
|
||||
{Device: "sdb", Serial: "UNRAID-SERIAL-B", Name: "disk1", Role: "data", Status: "online"},
|
||||
{Device: "sdc", Serial: "UNRAID-SERIAL-C", Name: "disk2", Role: "data", Status: "online"},
|
||||
{Device: "sdd", Serial: "UNRAID-SERIAL-D", Name: "disk3", Role: "data", Status: "online", SpunDown: true},
|
||||
{Device: "sde", Serial: "UNRAID-SERIAL-E", Name: "disk4", Role: "data", Status: "online"},
|
||||
{Device: "sdf", Name: "disk5", Role: "data", Status: "online"},
|
||||
}}
|
||||
want := map[string][]string{
|
||||
"sdb": {"smart_temp", "diskread", "diskwrite", "disk"},
|
||||
"/dev/sdc": {"smart_temp", "diskread", "diskwrite", "disk"},
|
||||
"sdd": {"diskread", "diskwrite", "disk"},
|
||||
"sde": {"diskread", "diskwrite", "disk"},
|
||||
"sdf": {"diskread", "diskwrite", "disk"},
|
||||
}
|
||||
for _, device := range []string{"sdb", "sdc", "sdd", "sde", "sdf"} {
|
||||
host.DiskIO = append(host.DiskIO, models.DiskIO{Device: device, ReadBytes: 1 << 20, WriteBytes: 1 << 20, IOTime: 100})
|
||||
}
|
||||
m.writeHostPhysicalDiskIOMetrics(host, now.Add(-30*time.Second))
|
||||
for i := range host.DiskIO {
|
||||
host.DiskIO[i].ReadBytes += 1 << 20
|
||||
host.DiskIO[i].WriteBytes += 1 << 20
|
||||
host.DiskIO[i].IOTime += 1000
|
||||
}
|
||||
m.writeHostPhysicalDiskIOMetrics(host, now)
|
||||
m.writeHostSMARTMetrics(host, now)
|
||||
store.Flush()
|
||||
|
||||
live := unifiedresources.NewRegistry(nil)
|
||||
live.IngestSnapshot(models.StateSnapshot{Hosts: []models.Host{host}})
|
||||
rehydrated := unifiedresources.NewRegistry(nil)
|
||||
rehydrated.IngestResources(live.List())
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
registry *unifiedresources.ResourceRegistry
|
||||
}{{"live", live}, {"rehydrated", rehydrated}} {
|
||||
disks := tc.registry.ListByType(unifiedresources.ResourceTypePhysicalDisk)
|
||||
if len(disks) != len(want) {
|
||||
t.Fatalf("%s: disks = %d, want %d", tc.name, len(disks), len(want))
|
||||
}
|
||||
for _, disk := range disks {
|
||||
metricNames, ok := want[disk.PhysicalDisk.DevPath]
|
||||
if !ok {
|
||||
t.Fatalf("%s: unexpected disk %s at %q", tc.name, disk.ID, disk.PhysicalDisk.DevPath)
|
||||
}
|
||||
target := tc.registry.MetricsTarget(disk.ID)
|
||||
if target == nil {
|
||||
t.Fatalf("%s: disk %s has no metrics target", tc.name, disk.PhysicalDisk.DevPath)
|
||||
}
|
||||
for _, metric := range metricNames {
|
||||
points, err := store.Query(target.ResourceType, target.ResourceID, metric, now.Add(-time.Minute), now.Add(time.Minute), 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(points) == 0 {
|
||||
t.Errorf("%s: disk %s metrics target %+v reads no %s history", tc.name, disk.PhysicalDisk.DevPath, *target, metric)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A SMART row found only by device path may describe the slot's previous
|
||||
// occupant. It is refused when its identity contradicts the Proxmox disk's,
|
||||
// judged only on what both producers report like for like: WWNs for every
|
||||
|
||||
@@ -4228,24 +4228,34 @@ func hostDiskIOMetricResourceID(host models.Host, io models.DiskIO, proxmoxDisks
|
||||
return ""
|
||||
}
|
||||
|
||||
smartDevice := diskinventory.DeviceToken(io.Device)
|
||||
smartMetricID := ""
|
||||
smartRowID := ""
|
||||
for _, disk := range host.Sensors.SMART {
|
||||
if disk.Standby {
|
||||
if !strings.EqualFold(diskinventory.DeviceToken(disk.Device), smartDevice) {
|
||||
continue
|
||||
}
|
||||
if strings.EqualFold(normalizeHostDiskDevice(disk.Device), device) {
|
||||
candidate := unifiedresources.HostSMARTDiskMetricID(host, disk)
|
||||
if smartMetricID != "" && smartMetricID != candidate {
|
||||
// Multiple controller members share this kernel block path.
|
||||
// The counter belongs to the aggregate device, not any member.
|
||||
return ""
|
||||
}
|
||||
smartMetricID = candidate
|
||||
// Multiple controller members share this kernel block path. The
|
||||
// counter belongs to the aggregate device, not any member. Judge that
|
||||
// on each row's own identity, standby rows included, since members
|
||||
// never take the path's Unraid serial.
|
||||
rowID := unifiedresources.HostSMARTDiskSourceID(host, disk)
|
||||
if smartRowID != "" && smartRowID != rowID {
|
||||
return ""
|
||||
}
|
||||
smartRowID = rowID
|
||||
if !disk.Standby {
|
||||
smartMetricID = unifiedresources.HostSMARTDiskMetricID(host, disk)
|
||||
}
|
||||
}
|
||||
if smartMetricID != "" {
|
||||
return smartMetricID
|
||||
}
|
||||
// A disk with no SMART reading to key it, spun down or unread, still
|
||||
// carries the serial its Unraid row reports.
|
||||
if unraidMetricID := unifiedresources.HostUnraidDeviceMetricID(host, device); unraidMetricID != "" {
|
||||
return unraidMetricID
|
||||
}
|
||||
|
||||
for _, pd := range proxmoxDisks {
|
||||
if pd.device == "" || pd.metricID == "" {
|
||||
|
||||
@@ -3550,6 +3550,49 @@ func TestHostDiskIOMetricResourceIDFallbacks(t *testing.T) {
|
||||
if got != "SATA-SERIAL-456" {
|
||||
t.Fatalf("proxmox fallback: expected SATA-SERIAL-456, got %q", got)
|
||||
}
|
||||
|
||||
// The disk resource carries the serial the host's Unraid inventory reports
|
||||
// when no SMART reading names the disk, so its I/O is filed under it.
|
||||
// Controller members behind one block device keep their own identity, so
|
||||
// the device's counter belongs to no member, even while one is in standby.
|
||||
// Two inventory rows on one device name no single disk. A legacy SMART
|
||||
// device label still names its block device.
|
||||
host.Sensors.SMART = append(host.Sensors.SMART,
|
||||
models.HostDiskSMART{Device: "sdb"},
|
||||
models.HostDiskSMART{Device: "sdc", Serial: "STANDBY-SERIAL", Standby: true},
|
||||
models.HostDiskSMART{Device: "sde", Controller: "ctrl0", Target: "megaraid,0"},
|
||||
models.HostDiskSMART{Device: "sde", Controller: "ctrl0", Target: "megaraid,1"},
|
||||
models.HostDiskSMART{Device: "sdg", WWN: "5000c500a1b2c3e0", Controller: "ctrl1", Target: "megaraid,0"},
|
||||
models.HostDiskSMART{Device: "/dev/sdg", WWN: "5000c500a1b2c3e1", Controller: "ctrl1", Target: "megaraid,1", Standby: true},
|
||||
models.HostDiskSMART{Device: "sdh [sat]", Serial: "LEGACY-LABEL-SERIAL"},
|
||||
)
|
||||
host.Unraid = &models.HostUnraidStorage{Disks: []models.HostUnraidDisk{
|
||||
{Device: "sda", Serial: "UNRAID-SERIAL-A"},
|
||||
{Device: "sdb", Serial: "UNRAID-SERIAL-B"},
|
||||
{Device: "sdc", Serial: "UNRAID-SERIAL-C"},
|
||||
{Device: "sdd"},
|
||||
{Device: "sde", Serial: "UNRAID-SERIAL-E"},
|
||||
{Device: "sdf", Serial: "UNRAID-SERIAL-F1"},
|
||||
{Device: "sdf", Serial: "UNRAID-SERIAL-F2"},
|
||||
}}
|
||||
for _, tc := range []struct {
|
||||
device string
|
||||
want string
|
||||
}{
|
||||
{"sda", "UNRAID-SERIAL-A"},
|
||||
{"sdb", "UNRAID-SERIAL-B"},
|
||||
{"sdc", "UNRAID-SERIAL-C"},
|
||||
{"sdd", "myhost:sdd"},
|
||||
{"sde", ""},
|
||||
{"sdf", "myhost:sdf"},
|
||||
{"sdg", ""},
|
||||
{"sdh", "LEGACY-LABEL-SERIAL"},
|
||||
{"nvme0n1", "NVME-SERIAL-123"},
|
||||
} {
|
||||
if got := hostDiskIOMetricResourceID(host, models.DiskIO{Device: tc.device}, proxmoxDisks); got != tc.want {
|
||||
t.Fatalf("device %s: expected %q, got %q", tc.device, tc.want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyHostReportSkipsMetricsAndSMARTWritesInMockMode(t *testing.T) {
|
||||
|
||||
@@ -905,7 +905,7 @@ func resourceFromHostSMARTDisk(host models.Host, disk models.HostDiskSMART) (Res
|
||||
}
|
||||
unraidDisk := matchUnraidDisk(host.Unraid, disk)
|
||||
model := strings.TrimSpace(disk.Model)
|
||||
serial := strings.TrimSpace(disk.Serial)
|
||||
serial := hostSMARTDiskSerial(host, disk, unraidDisk)
|
||||
diskType := strings.TrimSpace(disk.Type)
|
||||
temperature := disk.Temperature
|
||||
collection := diskinventory.CloneStatus(disk.Collection)
|
||||
@@ -914,9 +914,6 @@ func resourceFromHostSMARTDisk(host models.Host, disk models.HostDiskSMART) (Res
|
||||
if model == "" {
|
||||
model = strings.TrimSpace(unraidDisk.Model)
|
||||
}
|
||||
if serial == "" {
|
||||
serial = strings.TrimSpace(unraidDisk.Serial)
|
||||
}
|
||||
if diskType == "" {
|
||||
diskType = unraidDiskDiskType(*unraidDisk)
|
||||
}
|
||||
|
||||
@@ -565,6 +565,121 @@ func TestHostSMARTDiskSourceIDScopesHardwareIdentityToItsHost(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// A SMART row without a serial takes the one the host's Unraid inventory
|
||||
// reports for the disk, and its metrics key follows, because the disk resource
|
||||
// carries that serial and its metrics target reads it. The source ID stays the
|
||||
// row's own. A row's own serial, even a placeholder, is kept, as the adapter
|
||||
// keeps it, and the Unraid row then stays a disk of its own. So does a row on a
|
||||
// device path several rows share: controller members behind one block device
|
||||
// keep their own identity. A disk with no SMART row is keyed by its Unraid
|
||||
// serial alone, unless several Unraid rows name its device.
|
||||
func TestHostSMARTDiskMetricIDTakesTheSerialItsUnraidRowReports(t *testing.T) {
|
||||
host := models.Host{ID: "host-tower", Hostname: "tower", MachineID: "machine-tower", Status: "online", LastSeen: time.Now().UTC()}
|
||||
host.Unraid = &models.HostUnraidStorage{ArrayStarted: true, Disks: []models.HostUnraidDisk{
|
||||
{Name: "disk1", Device: "sdb", Role: "data", Serial: "UNRAID-B"},
|
||||
{Name: "disk2", Device: "sdc", Role: "data", Serial: "UNRAID-C"},
|
||||
{Name: "disk3", Device: "sdd", Role: "data", Serial: "UNRAID-D"},
|
||||
{Name: "disk4", Device: "sde", Role: "data", Serial: "UNRAID-E"},
|
||||
{Name: "disk5", Device: "sdf", Role: "data", Serial: "UNRAID-F"},
|
||||
{Name: "disk6", Device: "sdg", Role: "data"},
|
||||
{Name: "disk7", Device: "sdh", Role: "data", Serial: "UNRAID-H"},
|
||||
{Name: "disk8", Device: "sdi", Role: "data", Serial: "UNRAID-I1"},
|
||||
{Name: "disk9", Device: "sdi", Role: "data", Serial: "UNRAID-I2"},
|
||||
}}
|
||||
tests := []struct {
|
||||
name string
|
||||
disk models.HostDiskSMART
|
||||
wantSource string
|
||||
wantMetric string
|
||||
}{
|
||||
{
|
||||
name: "no hardware identity",
|
||||
disk: models.HostDiskSMART{Device: "/dev/sdb"},
|
||||
wantSource: "host-tower:sdb",
|
||||
wantMetric: "UNRAID-B",
|
||||
},
|
||||
{
|
||||
name: "standby row",
|
||||
disk: models.HostDiskSMART{Device: "sdc", Standby: true},
|
||||
wantSource: "host-tower:sdc",
|
||||
wantMetric: "UNRAID-C",
|
||||
},
|
||||
{
|
||||
name: "wwn only",
|
||||
disk: models.HostDiskSMART{Device: "sdd", WWN: "5000c500a1b2c3d4"},
|
||||
wantSource: "host-tower/physical-disk:5000c500a1b2c3d4",
|
||||
wantMetric: "UNRAID-D",
|
||||
},
|
||||
{
|
||||
name: "own serial",
|
||||
disk: models.HostDiskSMART{Device: "sde", Serial: "SMART-E"},
|
||||
wantSource: "host-tower/physical-disk:SMART-E",
|
||||
wantMetric: "SMART-E",
|
||||
},
|
||||
{
|
||||
name: "own placeholder serial",
|
||||
disk: models.HostDiskSMART{Device: "sdf", Serial: "0000000000"},
|
||||
wantSource: "host-tower:sdf",
|
||||
wantMetric: "host-tower:sdf",
|
||||
},
|
||||
{
|
||||
name: "unraid row without serial",
|
||||
disk: models.HostDiskSMART{Device: "sdg"},
|
||||
wantSource: "host-tower:sdg",
|
||||
wantMetric: "host-tower:sdg",
|
||||
},
|
||||
{
|
||||
name: "controller member sharing a path",
|
||||
disk: models.HostDiskSMART{Device: "sdh", WWN: "5000c500a1b2c3e0", Controller: "ctrl0", Target: "megaraid,0"},
|
||||
wantSource: "host-tower/physical-disk:5000c500a1b2c3e0",
|
||||
wantMetric: "5000c500a1b2c3e0",
|
||||
},
|
||||
{
|
||||
name: "second controller member on that path",
|
||||
disk: models.HostDiskSMART{Device: "/dev/sdh", WWN: "5000c500a1b2c3e1", Controller: "ctrl0", Target: "megaraid,1"},
|
||||
wantSource: "host-tower/physical-disk:5000c500a1b2c3e1",
|
||||
wantMetric: "5000c500a1b2c3e1",
|
||||
},
|
||||
}
|
||||
host.Sensors.SMART = make([]models.HostDiskSMART, 0, len(tests))
|
||||
for _, tt := range tests {
|
||||
host.Sensors.SMART = append(host.Sensors.SMART, tt.disk)
|
||||
}
|
||||
rr := NewRegistry(nil)
|
||||
rr.IngestSnapshot(models.StateSnapshot{Hosts: []models.Host{host}})
|
||||
targetBySourceID := make(map[string]string)
|
||||
for _, disk := range rr.ListByType(ResourceTypePhysicalDisk) {
|
||||
target := rr.MetricsTarget(disk.ID)
|
||||
if target == nil {
|
||||
continue
|
||||
}
|
||||
for _, sourceTarget := range rr.SourceTargets(disk.ID) {
|
||||
if sourceTarget.Source == SourceAgent {
|
||||
targetBySourceID[sourceTarget.SourceID] = target.ResourceID
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := HostSMARTDiskSourceID(host, tt.disk); got != tt.wantSource {
|
||||
t.Fatalf("HostSMARTDiskSourceID = %q, want %q", got, tt.wantSource)
|
||||
}
|
||||
if got := HostSMARTDiskMetricID(host, tt.disk); got != tt.wantMetric {
|
||||
t.Fatalf("HostSMARTDiskMetricID = %q, want %q", got, tt.wantMetric)
|
||||
}
|
||||
if got := targetBySourceID[tt.wantSource]; got != tt.wantMetric {
|
||||
t.Fatalf("disk metrics target = %q, want the writer's %q", got, tt.wantMetric)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
for device, want := range map[string]string{"/dev/sde": "UNRAID-E", "sdg": "", "sdi": "", "sdz": ""} {
|
||||
if got := HostUnraidDeviceMetricID(host, device); got != want {
|
||||
t.Fatalf("HostUnraidDeviceMetricID(%q) = %q, want %q", device, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A registry seeded from unified resources rebuilds each agent disk's source
|
||||
// mapping from the resource alone, after a JSON round trip drops per-source
|
||||
// parents. It must reproduce the key the agent's observation ingests under,
|
||||
|
||||
@@ -26,31 +26,93 @@ func PreferredPhysicalDiskMetricID(serial, wwn, fallback string) string {
|
||||
// machine: a dual-ported SAS shelf, cloned VMs with an explicit serial and
|
||||
// fixed-serial USB bridges report one identifier on several hosts, and the
|
||||
// registry keeps one resource per source ID. The key therefore carries the
|
||||
// reporting host. A disk without hardware identity keeps its historical
|
||||
// host/device/topology key, which is also its metrics key.
|
||||
// reporting host. A row without hardware identity keeps its historical
|
||||
// host/device/topology key, which is also its metrics key unless the host's
|
||||
// Unraid inventory reports a serial for the disk.
|
||||
func HostSMARTDiskSourceID(host models.Host, disk models.HostDiskSMART) string {
|
||||
if hardwareID := PreferredPhysicalDiskMetricID(disk.Serial, disk.WWN, ""); hardwareID != "" {
|
||||
return hostPhysicalDiskSourceID(host.ID, hardwareID)
|
||||
}
|
||||
return HostSMARTDiskMetricID(host, disk)
|
||||
return hostSMARTDiskKey(host, disk, "", "")
|
||||
}
|
||||
|
||||
// HostSMARTDiskMetricID returns the history key a host agent's SMART and disk
|
||||
// I/O metrics are written under. Like every physical-disk source, it prefers
|
||||
// the drive's serial or WWN, so a disk keeps one history across sources and
|
||||
// hosts; see PreferredPhysicalDiskMetricID.
|
||||
// hosts; see PreferredPhysicalDiskMetricID. The serial is the one the disk
|
||||
// resource carries (hostSMARTDiskSerial), which its metrics target reads.
|
||||
func HostSMARTDiskMetricID(host models.Host, disk models.HostDiskSMART) string {
|
||||
device := normalizePhysicalDiskDeviceToken(disk.Device)
|
||||
serial := hostSMARTDiskSerial(host, disk, matchUnraidDisk(host.Unraid, disk))
|
||||
return hostSMARTDiskKey(host, disk, serial, disk.WWN)
|
||||
}
|
||||
|
||||
func hostSMARTDiskKey(host models.Host, disk models.HostDiskSMART, serial, wwn string) string {
|
||||
return diskinventory.PreferredID(
|
||||
disk.Serial,
|
||||
disk.WWN,
|
||||
serial,
|
||||
wwn,
|
||||
strings.TrimSpace(host.ID),
|
||||
device,
|
||||
normalizePhysicalDiskDeviceToken(disk.Device),
|
||||
disk.Controller,
|
||||
disk.Target,
|
||||
)
|
||||
}
|
||||
|
||||
// hostSMARTDiskSerial returns the serial of the disk a SMART row describes: the
|
||||
// row's own, or, when the row reports none, the one the host's Unraid
|
||||
// inventory reports for the disk. smartctl reports no serial for a disk in
|
||||
// standby, and the Unraid row still names it. A row without a serial matches
|
||||
// its Unraid row by device path alone, so it takes no serial from a path
|
||||
// several of the host's rows share: controller members behind one kernel block
|
||||
// device, which the Unraid row describes as a whole.
|
||||
func hostSMARTDiskSerial(host models.Host, disk models.HostDiskSMART, unraidDisk *models.HostUnraidDisk) string {
|
||||
serial := strings.TrimSpace(disk.Serial)
|
||||
if serial != "" || unraidDisk == nil || hostSMARTDevicePathShared(host, disk) {
|
||||
return serial
|
||||
}
|
||||
return strings.TrimSpace(unraidDisk.Serial)
|
||||
}
|
||||
|
||||
// hostSMARTDevicePathShared reports whether more than one of the host's SMART
|
||||
// rows, the row itself among them, names the row's device path.
|
||||
func hostSMARTDevicePathShared(host models.Host, disk models.HostDiskSMART) bool {
|
||||
device := strings.ToLower(normalizePhysicalDiskDeviceToken(disk.Device))
|
||||
if device == "" {
|
||||
return false
|
||||
}
|
||||
rows := 0
|
||||
for _, other := range host.Sensors.SMART {
|
||||
if strings.ToLower(normalizePhysicalDiskDeviceToken(other.Device)) == device {
|
||||
rows++
|
||||
}
|
||||
}
|
||||
return rows > 1
|
||||
}
|
||||
|
||||
// HostUnraidDeviceMetricID returns the history key of the disk a host's Unraid
|
||||
// inventory reports at device: its usable serial, which the disk resource
|
||||
// carries and its metrics target reads. It returns "" when the inventory has
|
||||
// no row for the device, more than one, or a row without a usable serial.
|
||||
func HostUnraidDeviceMetricID(host models.Host, device string) string {
|
||||
device = strings.ToLower(normalizePhysicalDiskDeviceToken(device))
|
||||
if host.Unraid == nil || device == "" {
|
||||
return ""
|
||||
}
|
||||
var matched *models.HostUnraidDisk
|
||||
for i := range host.Unraid.Disks {
|
||||
if strings.ToLower(normalizePhysicalDiskDeviceToken(host.Unraid.Disks[i].Device)) != device {
|
||||
continue
|
||||
}
|
||||
if matched != nil {
|
||||
return ""
|
||||
}
|
||||
matched = &host.Unraid.Disks[i]
|
||||
}
|
||||
if matched == nil {
|
||||
return ""
|
||||
}
|
||||
return PreferredPhysicalDiskMetricID(matched.Serial, "", "")
|
||||
}
|
||||
|
||||
// HostUnraidDiskSourceID returns the registry source ID of a disk in a host's
|
||||
// Unraid inventory. It shares HostSMARTDiskSourceID's host-scoped serial key so
|
||||
// the SMART and Unraid observations of one disk keep one source mapping.
|
||||
|
||||
Reference in New Issue
Block a user