mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-23 20:25:56 +00:00
Read controller-member disk history at its written key
A physical disk behind a RAID controller (target such as megaraid,3) with no usable serial or WWN had its SMART history written under one key and read under another, so its temperature and SMART charts stayed empty. The host agent writes under HostSMARTDiskMetricID and Proxmox under PhysicalDiskMetricID, and both keys already scope the member (host-pve:sdd@ctrl0/megaraid,3). BuildMetricsTarget passed that source ID to PhysicalDiskMetaMetricID, which appended the topology again (host-pve:sdd@ctrl0/megaraid,3:sdd@ctrl0/megaraid,3). The reader now does what the Proxmox writer does: a fallback that already names the member is returned unchanged, and only a fallback without the topology gets it appended. That covers a Proxmox source ID from before members were scoped and the canonical resource ID a view falls back to. TrueNAS disks carry no controller target and are unaffected. SMART history needs no migration: no SMART writer used the doubled key, so samples already stored become readable. A linked node's agent with no SMART reading for a member, such as a standby row, files the device's I/O under the Proxmox disk's metrics target, which was the doubled key. That I/O now lands beside the member's SMART history, and samples already stored under the doubled key are left to age out. This carries forward the fix from PR 2661, whose branch conflicts with the disk ID stack it now sits on, and proves it against the real SMART and I/O writers in the live and rehydrated registries.
This commit is contained in:
@@ -4104,6 +4104,14 @@ ambiguous. Direct SATA, SAS, and NVMe device fallback IDs retain their legacy
|
||||
shape, while multiple controller members behind one block path add their
|
||||
controller target to the fallback identity. Per-member I/O must never inherit
|
||||
an aggregate controller counter.
|
||||
A linked node's agent with no SMART reading for a device, such as a member's
|
||||
standby row, files that device's I/O under the Proxmox disk's metrics target
|
||||
(`proxmoxPhysicalDiskMatchesForLinkedNode` reads `MetricResourceID`). For a
|
||||
member merged with its Proxmox row that target is the member's own SMART key,
|
||||
scoped to the member once (`PhysicalDiskMetaMetricID`), so its SMART and I/O
|
||||
history share one series. The target used to carry the member topology twice,
|
||||
and I/O already stored under that doubled key is left to age out rather than
|
||||
migrated. Proof: `TestIdentitylessControllerMembersReadTheirWritersHistory`.
|
||||
The same rules apply to SATA and NVMe inventory: direct-disk source IDs keep
|
||||
their historical shape, controller-member IDs add their member target, and
|
||||
cross-source correlation is scoped to the canonical parent node. A successful
|
||||
|
||||
@@ -3554,6 +3554,24 @@ remain preferred over device fallback. Direct SATA, SAS, and NVMe fallbacks
|
||||
retain their historical device-scoped shape; controller members sharing one
|
||||
block path add controller/target scope so they cannot collapse into one
|
||||
resource. Topology correlation is parent-scoped and ambiguity fails closed.
|
||||
That scope is added once. A controller member without a usable serial or WWN
|
||||
keeps its history under a source ID that already names the member: the host
|
||||
agent's `HostSMARTDiskMetricID`, and the Proxmox `PhysicalDiskMetricID`, which
|
||||
returns `ProxmoxPhysicalDiskSourceID`. `PhysicalDiskMetaMetricID` therefore
|
||||
returns a fallback that already ends in the disk's own device, controller and
|
||||
target unchanged as the metrics target. It appends that topology only to a
|
||||
fallback without it: a Proxmox source ID from before members were scoped,
|
||||
which `PhysicalDiskMetricID` scopes the same way, or the canonical resource ID
|
||||
`PhysicalDiskView.MetricResourceID` falls back to. The suffix survives a merge
|
||||
because a member target only reaches a disk together with the controller the
|
||||
agent reported beside it (the multiplexed smartctl path sets both), and the
|
||||
Proxmox row copies both from that agent row. A registry rehydrated from
|
||||
persisted resources rebuilds the agent source ID in the same scoped shape
|
||||
(`seedAgentPhysicalDiskSourceIDLocked`). TrueNAS disks carry no controller
|
||||
target and keep their fallback. The reader used to append the topology to an
|
||||
already scoped source ID as well, so such a member's chart read a key no
|
||||
writer used. Proof: `TestPhysicalDiskMetaMetricIDScopesAControllerMemberOnce`,
|
||||
`TestIdentitylessControllerMembersReadTheirWritersHistory`.
|
||||
|
||||
Host reports, monitoring models, registry merges, typed physical-disk views,
|
||||
API resources, websocket/REST snapshot merges, and storage presentation must
|
||||
|
||||
@@ -373,6 +373,146 @@ func TestWriteHostSMARTMetricsRecordsOnlyCollectedTemperatures(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// A controller member without a usable serial or WWN keys its history by its
|
||||
// source ID, which already names the member behind the shared block path: the
|
||||
// host agent writes under HostSMARTDiskMetricID, Proxmox under
|
||||
// PhysicalDiskMetricID, which returns ProxmoxPhysicalDiskSourceID. The metrics
|
||||
// target a chart reads must be that key, in the live registry and in one
|
||||
// rehydrated from persisted resources, not the key with the member's topology
|
||||
// appended a second time, or every sample those writers store goes unread. The
|
||||
// agent's I/O for a lone member is filed under the member's SMART key. A linked
|
||||
// node's agent files the I/O of a member it reads no SMART for through the
|
||||
// Proxmox disk's metrics target, so that I/O must share the member's SMART key.
|
||||
func TestIdentitylessControllerMembersReadTheirWritersHistory(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() })
|
||||
state := models.NewState()
|
||||
adapter := unifiedresources.NewMonitorAdapter(unifiedresources.NewRegistry(nil))
|
||||
m := &Monitor{state: state, resourceStore: adapter, metricsStore: store, rateTracker: NewRateTracker()}
|
||||
|
||||
now := time.Now().UTC().Truncate(time.Second)
|
||||
collected := func() *diskinventory.CollectionStatus {
|
||||
return &diskinventory.CollectionStatus{Temperature: diskinventory.Available("smartctl")}
|
||||
}
|
||||
state.UpdateNodesForInstance("pve", []models.Node{
|
||||
{ID: "pve-node1", Name: "node1", Instance: "pve", Status: "online", LastSeen: now},
|
||||
{ID: "pve-node2", Name: "node2", Instance: "pve", Status: "online", LastSeen: now, LinkedAgentID: "agent-node2"},
|
||||
})
|
||||
standalone := models.Host{ID: "host-pve", Hostname: "standalone", MachineID: "machine-standalone", Status: "online", LastSeen: now,
|
||||
Sensors: models.HostSensorSummary{SMART: []models.HostDiskSMART{
|
||||
{Device: "sdd", Controller: "ctrl0", Target: "megaraid,3", Type: "sas", Temperature: 33, Collection: collected()},
|
||||
{Device: "sdd", Controller: "ctrl0", Target: "megaraid,4", Type: "sas", Temperature: 34, Collection: collected()},
|
||||
{Device: "/dev/sde", Controller: "ctrl0", Target: "megaraid,5", Type: "sas", Temperature: 35, Collection: collected()},
|
||||
}},
|
||||
DiskIO: []models.DiskIO{{Device: "sde", ReadBytes: 1 << 20, WriteBytes: 1 << 20, IOTime: 100}},
|
||||
}
|
||||
linked := models.Host{ID: "agent-node2", Hostname: "node2", MachineID: "machine-node2", LinkedNodeID: "pve-node2", Status: "online", LastSeen: now,
|
||||
Sensors: models.HostSensorSummary{SMART: []models.HostDiskSMART{
|
||||
{Device: "sdc", Controller: "ctrl1", Target: "megaraid,1", Type: "sas", Standby: true, Collection: &diskinventory.CollectionStatus{
|
||||
Temperature: diskinventory.Unavailable("smartctl", "disk is in standby")}},
|
||||
}},
|
||||
DiskIO: []models.DiskIO{{Device: "sdc", ReadBytes: 1 << 20, WriteBytes: 1 << 20, IOTime: 100}},
|
||||
}
|
||||
state.UpsertHost(standalone)
|
||||
state.UpsertHost(linked)
|
||||
pveMember := models.PhysicalDisk{
|
||||
ID: unifiedresources.ProxmoxPhysicalDiskSourceID("pve", "node1", "/dev/sdx", "", "megaraid,0"),
|
||||
Instance: "pve", Node: "node1", DevPath: "/dev/sdx", Target: "megaraid,0", Type: "sas", Temperature: 40, LastChecked: now,
|
||||
}
|
||||
pveBlockPath := models.PhysicalDisk{
|
||||
ID: unifiedresources.ProxmoxPhysicalDiskSourceID("pve", "node2", "/dev/sdc", "", ""),
|
||||
Instance: "pve", Node: "node2", DevPath: "/dev/sdc", Serial: "unknown", Type: "sas", LastChecked: now,
|
||||
}
|
||||
state.UpdatePhysicalDisks("pve", []models.PhysicalDisk{pveMember, pveBlockPath})
|
||||
adapter.PopulateFromSnapshot(state.GetSnapshot())
|
||||
|
||||
hosts := []models.Host{standalone, linked}
|
||||
for _, host := range hosts {
|
||||
m.writeHostPhysicalDiskIOMetrics(host, now.Add(-30*time.Second))
|
||||
}
|
||||
for _, host := range hosts {
|
||||
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)
|
||||
}
|
||||
m.writeSMARTMetrics(pveMember, now)
|
||||
store.Flush()
|
||||
|
||||
smartTemp := []string{"smart_temp"}
|
||||
diskIO := []string{"diskread", "diskwrite", "disk"}
|
||||
want := map[string]struct {
|
||||
key string
|
||||
metrics []string
|
||||
}{
|
||||
"megaraid,3": {unifiedresources.HostSMARTDiskMetricID(standalone, standalone.Sensors.SMART[0]), smartTemp},
|
||||
"megaraid,4": {unifiedresources.HostSMARTDiskMetricID(standalone, standalone.Sensors.SMART[1]), smartTemp},
|
||||
"megaraid,5": {unifiedresources.HostSMARTDiskMetricID(standalone, standalone.Sensors.SMART[2]), append(smartTemp, diskIO...)},
|
||||
"megaraid,0": {unifiedresources.PhysicalDiskMetricID(pveMember), smartTemp},
|
||||
"megaraid,1": {unifiedresources.HostSMARTDiskMetricID(linked, linked.Sensors.SMART[0]), diskIO},
|
||||
}
|
||||
for target, member := range map[string]string{
|
||||
"megaraid,3": "host-pve:sdd@ctrl0/megaraid,3",
|
||||
"megaraid,5": "host-pve:sde@ctrl0/megaraid,5",
|
||||
"megaraid,0": "pve-node1--dev-sdx:sdx@/megaraid,0",
|
||||
"megaraid,1": "agent-node2:sdc@ctrl1/megaraid,1",
|
||||
} {
|
||||
if want[target].key != member {
|
||||
t.Fatalf("%s: writer key = %q, want the member scoped once %q", target, want[target].key, member)
|
||||
}
|
||||
}
|
||||
|
||||
live := unifiedresources.NewRegistry(nil)
|
||||
live.IngestSnapshot(state.GetSnapshot())
|
||||
payload, err := json.Marshal(live.List())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var persisted []unifiedresources.Resource
|
||||
if err := json.Unmarshal(payload, &persisted); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rehydrated := unifiedresources.NewRegistry(nil)
|
||||
rehydrated.IngestResources(persisted)
|
||||
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 with the standby member merged into its Proxmox row", tc.name, len(disks), len(want))
|
||||
}
|
||||
for _, disk := range disks {
|
||||
member, ok := want[disk.PhysicalDisk.Target]
|
||||
if !ok {
|
||||
t.Fatalf("%s: unexpected disk %s at %q target %q", tc.name, disk.ID, disk.PhysicalDisk.DevPath, disk.PhysicalDisk.Target)
|
||||
}
|
||||
target := tc.registry.MetricsTarget(disk.ID)
|
||||
if target == nil || target.ResourceType != "disk" || target.ResourceID != member.key {
|
||||
t.Errorf("%s: %s metrics target = %+v, want the writer's disk/%s", tc.name, disk.PhysicalDisk.Target, target, member.key)
|
||||
continue
|
||||
}
|
||||
for _, metric := range member.metrics {
|
||||
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: %s metrics target %s reads no %s history", tc.name, disk.PhysicalDisk.Target, target.ResourceID, metric)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A dual-ported SAS shelf, cloned VMs with an explicit serial and fixed-serial
|
||||
// USB bridges report one usable serial on several hosts. The registry keeps a
|
||||
// disk per host, and each must keep its own agent source target, in the live
|
||||
|
||||
@@ -824,6 +824,84 @@ func TestSplitAgentDisksOnTwoMachinesNeverOverwriteEachOther(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// The metrics reader scopes a controller member's fallback key to the member
|
||||
// once, as the writers do. A source ID that already names the member is the
|
||||
// writer's key and stays unchanged. A Proxmox source ID from before members
|
||||
// were scoped, and the canonical resource ID a view falls back to, get the
|
||||
// member topology appended. A disk that is no controller member keeps its
|
||||
// fallback.
|
||||
func TestPhysicalDiskMetaMetricIDScopesAControllerMemberOnce(t *testing.T) {
|
||||
agent := models.Host{ID: "host-pve"}
|
||||
agentMember := models.HostDiskSMART{Device: "sdd", Controller: "ctrl0", Target: "megaraid,3"}
|
||||
labelledMember := models.HostDiskSMART{Device: "sdc [megaraid,1]", Controller: "sdc", Target: "megaraid,1"}
|
||||
pveMember := models.PhysicalDisk{
|
||||
ID: ProxmoxPhysicalDiskSourceID("pve", "node1", "/dev/sdx", "", "megaraid,0"),
|
||||
Instance: "pve", Node: "node1", DevPath: "/dev/sdx", Target: "megaraid,0",
|
||||
}
|
||||
legacyPVEMember := pveMember
|
||||
legacyPVEMember.ID = "pve-node1--dev-sdx"
|
||||
tests := []struct {
|
||||
name string
|
||||
meta PhysicalDiskMeta
|
||||
fallback string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "agent member source ID",
|
||||
meta: PhysicalDiskMeta{DevPath: "sdd", Controller: "ctrl0", Target: "megaraid,3"},
|
||||
fallback: HostSMARTDiskSourceID(agent, agentMember),
|
||||
want: HostSMARTDiskMetricID(agent, agentMember),
|
||||
},
|
||||
{
|
||||
name: "agent member reported under its smartctl label, merged with its Proxmox path",
|
||||
meta: PhysicalDiskMeta{DevPath: "/dev/sdc", Controller: "sdc", Target: "megaraid,1"},
|
||||
fallback: HostSMARTDiskSourceID(agent, labelledMember),
|
||||
want: HostSMARTDiskMetricID(agent, labelledMember),
|
||||
},
|
||||
{
|
||||
name: "proxmox member source ID",
|
||||
meta: PhysicalDiskMeta{DevPath: "/dev/sdx", Target: "megaraid,0"},
|
||||
fallback: pveMember.ID,
|
||||
want: PhysicalDiskMetricID(pveMember),
|
||||
},
|
||||
{
|
||||
name: "proxmox member source ID from before members were scoped",
|
||||
meta: PhysicalDiskMeta{DevPath: "/dev/sdx", Target: "megaraid,0"},
|
||||
fallback: legacyPVEMember.ID,
|
||||
want: PhysicalDiskMetricID(legacyPVEMember),
|
||||
},
|
||||
{
|
||||
name: "canonical resource ID",
|
||||
meta: PhysicalDiskMeta{DevPath: "/dev/sdx", Target: "megaraid,0"},
|
||||
fallback: "physical_disk-0123456789abcdef",
|
||||
want: "physical_disk-0123456789abcdef:sdx@/megaraid,0",
|
||||
},
|
||||
{
|
||||
name: "no controller member",
|
||||
meta: PhysicalDiskMeta{DevPath: "/dev/sda"},
|
||||
fallback: "truenas-system:disk:sda",
|
||||
want: "truenas-system:disk:sda",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := PhysicalDiskMetaMetricID(&tt.meta, tt.fallback); got != tt.want {
|
||||
t.Fatalf("PhysicalDiskMetaMetricID(%q) = %q, want %q", tt.fallback, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
for name, key := range map[string]string{
|
||||
"agent member": HostSMARTDiskMetricID(agent, agentMember),
|
||||
"proxmox member": PhysicalDiskMetricID(pveMember),
|
||||
"legacy proxmox member": PhysicalDiskMetricID(legacyPVEMember),
|
||||
"labelled agent member": HostSMARTDiskMetricID(agent, labelledMember),
|
||||
} {
|
||||
if strings.Count(key, "@") != 1 {
|
||||
t.Fatalf("%s writer key %q, want the member topology once", name, key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsUnsupportedLegacyResourceIDAlias(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
|
||||
@@ -292,6 +292,14 @@ func PhysicalDiskMetricID(disk models.PhysicalDisk) string {
|
||||
return strings.TrimSpace(fallback)
|
||||
}
|
||||
|
||||
// PhysicalDiskMetaMetricID returns the history key metrics readers use for a
|
||||
// physical disk resource. fallback keys a disk without a usable serial or WWN.
|
||||
// The writers key a controller member's history by a source ID that already
|
||||
// names the member (HostSMARTDiskMetricID, ProxmoxPhysicalDiskSourceID), so,
|
||||
// as PhysicalDiskMetricID does, such a fallback is returned unchanged. Only a
|
||||
// fallback without the member's topology gets it appended: a Proxmox source ID
|
||||
// from before members were scoped, or the canonical resource ID a view falls
|
||||
// back to.
|
||||
func PhysicalDiskMetaMetricID(disk *PhysicalDiskMeta, fallback string) string {
|
||||
if disk == nil {
|
||||
return strings.TrimSpace(fallback)
|
||||
@@ -302,15 +310,19 @@ func PhysicalDiskMetaMetricID(disk *PhysicalDiskMeta, fallback string) string {
|
||||
if wwn := strings.TrimSpace(disk.WWN); diskinventory.IsUsableHardwareID(wwn) {
|
||||
return wwn
|
||||
}
|
||||
if diskinventory.IsControllerMemberTarget(disk.Target) && strings.TrimSpace(fallback) != "" {
|
||||
return diskinventory.PreferredID(
|
||||
if fallback = strings.TrimSpace(fallback); diskinventory.IsControllerMemberTarget(disk.Target) && fallback != "" {
|
||||
scoped := diskinventory.PreferredID(
|
||||
"",
|
||||
"",
|
||||
strings.TrimSpace(fallback),
|
||||
fallback,
|
||||
disk.DevPath,
|
||||
disk.Controller,
|
||||
disk.Target,
|
||||
)
|
||||
if member := strings.TrimPrefix(scoped, fallback); member != "" && strings.HasSuffix(fallback, member) {
|
||||
return fallback
|
||||
}
|
||||
return scoped
|
||||
}
|
||||
return PreferredPhysicalDiskMetricID(disk.Serial, disk.WWN, fallback)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user