Identify drives by a Scrutiny UUID instead of wwn (#960)

* Generate a UUIDv5 from a random namespace  based on WWN, model name, and serial number
* Migrate sqlite and influxdb data accordingly
* Update frontend API routes and components
* Fixes #923
This commit is contained in:
Aram Akhavan
2026-03-25 20:16:17 -07:00
committed by GitHub
parent e4c40f7e80
commit c3b2eb2b4f
69 changed files with 815 additions and 402 deletions
+3 -7
View File
@@ -101,15 +101,11 @@ func (d *Detect) SmartCtlInfo(device *models.Device) error {
device.WWN = strings.ToLower(wwn.ToString())
d.Logger.Debugf("NAA: %d OUI: %d Id: %d => WWN: %s", wwn.Naa, wwn.Oui, wwn.Id, device.WWN)
} else {
d.Logger.Info("Using WWN Fallback")
d.Logger.Debug("Using WWN Fallback")
d.wwnFallback(device)
}
if len(device.WWN) == 0 {
// no WWN populated after WWN lookup and fallback. we need to throw an error
errMsg := fmt.Sprintf("no WWN (or fallback) populated for device: %s. Device will be registered, but no data will be published for this device. ", device.DeviceName)
d.Logger.Errorf("%v", errMsg)
return fmt.Errorf("%v", errMsg)
}
device.ScrutinyUUID = GenerateScrutinyUUID(device.ModelName, device.SerialNumber, device.WWN)
return nil
}
+3 -8
View File
@@ -1,10 +1,11 @@
package detect
import (
"strings"
"github.com/analogj/scrutiny/collector/pkg/common/shell"
"github.com/analogj/scrutiny/collector/pkg/models"
"github.com/jaypipes/ghw"
"strings"
)
func DevicePrefix() string {
@@ -89,7 +90,7 @@ func (d *Detect) findMissingDevices(detectedDevices []models.Device) ([]models.D
return missingDevices, nil
}
//WWN values NVMe and SCSI
// WWN values NVMe and SCSI
func (d *Detect) wwnFallback(detectedDevice *models.Device) {
block, err := ghw.Block()
if err == nil {
@@ -102,12 +103,6 @@ func (d *Detect) wwnFallback(detectedDevice *models.Device) {
}
}
//no WWN found, or could not open Block devices. Either way, fallback to serial number
if len(detectedDevice.WWN) == 0 {
d.Logger.Debugf("WWN is empty, falling back to serial number: %s", detectedDevice.SerialNumber)
detectedDevice.WWN = detectedDevice.SerialNumber
}
//wwn must always be lowercase.
detectedDevice.WWN = strings.ToLower(detectedDevice.WWN)
}
+3 -8
View File
@@ -1,10 +1,11 @@
package detect
import (
"strings"
"github.com/analogj/scrutiny/collector/pkg/common/shell"
"github.com/analogj/scrutiny/collector/pkg/models"
"github.com/jaypipes/ghw"
"strings"
)
func DevicePrefix() string {
@@ -27,7 +28,7 @@ func (d *Detect) Start() ([]models.Device, error) {
return detectedDevices, nil
}
//WWN values NVMe and SCSI
// WWN values NVMe and SCSI
func (d *Detect) wwnFallback(detectedDevice *models.Device) {
block, err := ghw.Block()
if err == nil {
@@ -40,12 +41,6 @@ func (d *Detect) wwnFallback(detectedDevice *models.Device) {
}
}
//no WWN found, or could not open Block devices. Either way, fallback to serial number
if len(detectedDevice.WWN) == 0 {
d.Logger.Debugf("WWN is empty, falling back to serial number: %s", detectedDevice.SerialNumber)
detectedDevice.WWN = detectedDevice.SerialNumber
}
//wwn must always be lowercase.
detectedDevice.WWN = strings.ToLower(detectedDevice.WWN)
}
-6
View File
@@ -45,12 +45,6 @@ func (d *Detect) wwnFallback(detectedDevice *models.Device) {
}
}
//no WWN found, or could not open Block devices. Either way, fallback to serial number
if len(detectedDevice.WWN) == 0 {
d.Logger.Debugf("WWN is empty, falling back to serial number: %s", detectedDevice.SerialNumber)
detectedDevice.WWN = detectedDevice.SerialNumber
}
//wwn must always be lowercase.
detectedDevice.WWN = strings.ToLower(detectedDevice.WWN)
}
+2 -10
View File
@@ -3,7 +3,6 @@ package detect
import (
"github.com/analogj/scrutiny/collector/pkg/common/shell"
"github.com/analogj/scrutiny/collector/pkg/models"
"strings"
)
func DevicePrefix() string {
@@ -26,14 +25,7 @@ func (d *Detect) Start() ([]models.Device, error) {
return detectedDevices, nil
}
//WWN values NVMe and SCSI
// WWN values NVMe and SCSI
func (d *Detect) wwnFallback(detectedDevice *models.Device) {
//fallback to serial number
if len(detectedDevice.WWN) == 0 {
detectedDevice.WWN = detectedDevice.SerialNumber
}
//wwn must always be lowercase.
detectedDevice.WWN = strings.ToLower(detectedDevice.WWN)
// No fallback on windows
}
+16
View File
@@ -0,0 +1,16 @@
package detect
import (
"github.com/gofrs/uuid/v5"
)
// Randomly generated UUID v4 namespace for Scrutiny
var ScrutinyNamespaceUUID = uuid.Must(uuid.FromString("3ea22b35-682b-49fb-a655-abffed108e48"))
// WWN's are not actually unique so we use Model Name and Serial Number
// to hopefully create something that is actually unique despite
// manufacturer laziness
func GenerateScrutinyUUID(modelName string, serialNumber string, wwn string) uuid.UUID {
name := modelName + serialNumber + wwn
return uuid.NewV5(ScrutinyNamespaceUUID, name)
}
@@ -0,0 +1,67 @@
package detect
import (
"bytes"
"encoding/json"
"os"
"testing"
"github.com/analogj/scrutiny/collector/pkg/models"
"github.com/analogj/scrutiny/webapp/backend/pkg/models/collector"
"github.com/gofrs/uuid/v5"
"github.com/stretchr/testify/require"
)
func TestGenerateScrutinyUUID(t *testing.T) {
t.Run("NVMe device from test data", func(t *testing.T) {
testData, err := os.ReadFile("testdata/smartctl_info_nvme.json")
require.NoError(t, err)
var smartInfo collector.SmartInfo
err = json.Unmarshal(testData, &smartInfo)
require.NoError(t, err)
device := &models.Device{
ModelName: smartInfo.ModelName,
SerialNumber: smartInfo.SerialNumber,
}
// NVMe drives don't have a WWN
// so scrutiny falls back to serial number
device.WWN = device.SerialNumber
uuid := GenerateScrutinyUUID(device.ModelName, device.SerialNumber, device.WWN)
require.NotEmpty(t, uuid.String(), "Generated UUID should not be empty")
require.Equal(t, uint8(5), uuid.Version(), "Expected UUID version 5")
uuid2 := GenerateScrutinyUUID(device.ModelName, device.SerialNumber, device.WWN)
require.True(t, bytes.Equal(uuid.Bytes(), uuid2.Bytes()), "UUID generation should be deterministic for the same input")
})
// Test with different device data to ensure uniqueness
t.Run("different devices produce different UUIDs", func(t *testing.T) {
device1 := models.Device{
ModelName: "Samsung SSD 860 EVO 1TB",
SerialNumber: "S3ZANX0K123456A",
WWN: "5002538e40a22954",
}
device2 := device1
device2.SerialNumber = "S3ZANX0K123456B"
uuid1 := GenerateScrutinyUUID(device1.ModelName, device1.SerialNumber, device1.WWN)
uuid2 := GenerateScrutinyUUID(device2.ModelName, device2.SerialNumber, device2.WWN)
require.False(t, bytes.Equal(uuid1.Bytes(), uuid2.Bytes()), "Different devices should produce different UUIDs")
})
}
func TestScrutinyNamespaceUUID(t *testing.T) {
// Make sure no one changes the namespace
expectedNamespace, err := uuid.FromString("3ea22b35-682b-49fb-a655-abffed108e48")
if err != nil {
t.Fatalf("Failed to parse expected namespace UUID: %v", err)
}
require.True(t, bytes.Equal(ScrutinyNamespaceUUID.Bytes(), expectedNamespace.Bytes()), "Scrutiny Namespace UUID should never change")
}