Files
pulse/pkg/diskinventory/identity_conflict.go
T
rcourtman 8d00d54740 Stop a stale agent SMART row lending a replaced disk its identity
The linked host agent's SMART merge falls back to matching a Proxmox
disk by device path, and a path names a slot, not a disk. When a disk
is swapped before the agent reports again, or after the agent went
silent and kept its last rows, the row for the old disk matched the
replacement. It filled or, for SAS, promoted the old serial onto it,
which keyed the replacement to the old disk's canonical resource and
metric history, and lent it the old disk's temperature and failed
health. For SAS the registry's same-path join did the same at the
canonical level even when monitoring kept the rows apart.

Both now refuse a row whose identity contradicts the disk's, judged
only on what both producers report like for like. WWNs count for every
disk. Serials count only where Proxmox's serial is the drive's own, as
smartctl's is: NVMe namespaces, and disks presented with SCSI vendor
"ATA", whose serial udev's ata_id reads from IDENTIFY. Elsewhere
Proxmox's serial may be a SAS address, a SCSI designator or a USB
bridge's own serial (#1595). Reporter spellings are not disagreement:
udev writes whitespace in serials as underscores, its T10 designator
for an ATA drive puts "ATA" and the model before the drive serial, and
its ID_WWN keeps only the first 64 bits of an NAA 6 identifier. The WWN match step compares normalized identity too,
so the agent's 5-c50-a1b2c3d4 spelling finds the Proxmox
0x5000c500a1b2c3d4 disk.

Proxmox's disk inventory reports a serial or WWN udev cannot read as
the literal "unknown". The poller recorded it as a collected serial, so
the collection state hid the gap and the agent's serial never filled
it. It is now recorded as empty, and retained evidence carries only a
usable previous serial so an older record cannot restore it. Identity
keys already ignored the placeholder.
2026-10-07 12:16:36 +01:00

62 lines
2.7 KiB
Go

package diskinventory
import "strings"
// HardwareIdentityConflict reports whether two observations of one disk slot
// carry hardware identities that name different disks: nothing agrees across
// serial and WWN (HardwareIdentityMatch), and at least one field reported on
// both sides disagrees. A field missing or unusable on either side is no
// evidence, so an observation without identity never conflicts.
//
// Reporters spell one identity differently, and spelling is not disagreement:
// - serials are compared on letters and digits only, because udev rewrites
// whitespace and unusual characters in a serial as underscores while
// smartctl keeps them, and a T10 vendor designator for an ATA drive
// ("ATA", the model, then the drive serial) is no evidence against the
// serial it ends with, because udev reports one when a SCSI-ATA
// translation layer hides IDENTIFY from it;
// - a WWN that begins with the other is no evidence, because udev's ID_WWN
// carries only the first 64 bits of a 128-bit NAA 6 identifier that sysfs
// and smartctl report in full.
//
// A caller blanks a field its producer may not report as disk identity, such
// as a SAS disk's serial, where Proxmox may report a SAS address, and checks
// HardwareIdentityMatch with the field kept if it may still show agreement.
func HardwareIdentityConflict(leftSerial, leftWWN, rightSerial, rightWWN string) bool {
if HardwareIdentityMatch(leftSerial, leftWWN, rightSerial, rightWWN) {
return false
}
return serialsDisagree(leftSerial, rightSerial) || wwnsDisagree(leftWWN, rightWWN)
}
func serialsDisagree(left, right string) bool {
left, right = compactSerial(normalizeHardwareID(left)), compactSerial(normalizeHardwareID(right))
return left != "" && right != "" && left != right &&
!isATADesignatorOf(left, right) && !isATADesignatorOf(right, left)
}
// isATADesignatorOf reports whether designator, compacted, is the T10 vendor
// designator of the ATA drive whose serial is given: the "ata" vendor field,
// a model, then that serial.
func isATADesignatorOf(designator, serial string) bool {
const vendor = "ata"
return len(designator) > len(vendor)+len(serial) &&
strings.HasPrefix(designator, vendor) && strings.HasSuffix(designator, serial)
}
func compactSerial(value string) string {
var builder strings.Builder
for _, char := range value {
if (char >= 'a' && char <= 'z') || (char >= '0' && char <= '9') {
builder.WriteRune(char)
}
}
return builder.String()
}
func wwnsDisagree(left, right string) bool {
left, right = normalizeHardwareWWN(left), normalizeHardwareWWN(right)
return left != "" && right != "" &&
!strings.HasPrefix(left, right) && !strings.HasPrefix(right, left)
}