diff --git a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md index 64a343aa03..faec9ec737 100644 --- a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md +++ b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md @@ -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 diff --git a/docs/release-control/v6/internal/subsystems/monitoring.md b/docs/release-control/v6/internal/subsystems/monitoring.md index ec49474ffd..f1d7dce1a5 100644 --- a/docs/release-control/v6/internal/subsystems/monitoring.md +++ b/docs/release-control/v6/internal/subsystems/monitoring.md @@ -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 `:` 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 diff --git a/docs/release-control/v6/internal/subsystems/unified-resources.md b/docs/release-control/v6/internal/subsystems/unified-resources.md index 9c28cda4e8..c91656d065 100644 --- a/docs/release-control/v6/internal/subsystems/unified-resources.md +++ b/docs/release-control/v6/internal/subsystems/unified-resources.md @@ -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 diff --git a/internal/monitoring/issue1595_collection_trust_test.go b/internal/monitoring/issue1595_collection_trust_test.go index a484609166..b79ac63f09 100644 --- a/internal/monitoring/issue1595_collection_trust_test.go +++ b/internal/monitoring/issue1595_collection_trust_test.go @@ -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 diff --git a/internal/monitoring/monitor_agents.go b/internal/monitoring/monitor_agents.go index 589516c78f..85599d39e7 100644 --- a/internal/monitoring/monitor_agents.go +++ b/internal/monitoring/monitor_agents.go @@ -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 == "" { diff --git a/internal/monitoring/monitor_host_agents_test.go b/internal/monitoring/monitor_host_agents_test.go index a55f0881a3..baef427792 100644 --- a/internal/monitoring/monitor_host_agents_test.go +++ b/internal/monitoring/monitor_host_agents_test.go @@ -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) { diff --git a/internal/unifiedresources/adapters.go b/internal/unifiedresources/adapters.go index e9386c81f0..167af4c6b1 100644 --- a/internal/unifiedresources/adapters.go +++ b/internal/unifiedresources/adapters.go @@ -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) } diff --git a/internal/unifiedresources/canonical_ids_types_test.go b/internal/unifiedresources/canonical_ids_types_test.go index 149627b6b8..4be51f17b9 100644 --- a/internal/unifiedresources/canonical_ids_types_test.go +++ b/internal/unifiedresources/canonical_ids_types_test.go @@ -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, diff --git a/internal/unifiedresources/physical_disk_ids.go b/internal/unifiedresources/physical_disk_ids.go index d4e55d46e8..3e96433b1a 100644 --- a/internal/unifiedresources/physical_disk_ids.go +++ b/internal/unifiedresources/physical_disk_ids.go @@ -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.