new device detection engine (OS aware). Uses smartctl --scan to detect drives (and conditionally uses jaypipes/ghw). WWN is calculated from smartctl data, then retrieved from GHW, and fallsback to serial number. WWN calcuation code is based on IEEE spec, for "Registered" IEEE format - NAA5. TODO: support NAA6 and other formats?

pull/38/head
Jason Kulatunga 4 years ago
parent f53833d617
commit 23d5b86b1b

@ -3,15 +3,8 @@ package collector
import (
"bytes"
"encoding/json"
"errors"
"github.com/analogj/scrutiny/collector/pkg/models"
"github.com/jaypipes/ghw"
"github.com/sirupsen/logrus"
"io"
"net/http"
"os"
"os/exec"
"path"
"time"
)
@ -21,77 +14,6 @@ type BaseCollector struct {
logger *logrus.Entry
}
func (c *BaseCollector) DetectStorageDevices() ([]models.Device, error) {
//availableDisksJson, err := c.ExecCmd("smartctl", []string{"-j", "--scan"}, "", os.Environ())
//if err != nil {
// c.logger.Errorf("Error getting block storage info: %v", err)
// return nil, err
//}
//
//var smartctlScan models.Scan
//err = json.Unmarshal([]byte(availableDisksJson), &smartctlScan)
//if err != nil {
// return nil, err
//}
block, err := ghw.Block()
if err != nil {
c.logger.Errorf("Error getting block storage info: %v", err)
return nil, err
}
approvedDisks := []models.Device{}
for _, disk := range block.Disks {
// ignore optical drives and floppy disks
if disk.DriveType == ghw.DRIVE_TYPE_FDD || disk.DriveType == ghw.DRIVE_TYPE_ODD {
c.logger.Debugf(" => Ignore: Optical or floppy disk - (found %s)\n", disk.DriveType.String())
continue
}
// ignore removable disks
if disk.IsRemovable {
c.logger.Debugf(" => Ignore: Removable disk (%v)\n", disk.IsRemovable)
continue
}
// ignore virtual disks & mobile phone storage devices
if disk.StorageController == ghw.STORAGE_CONTROLLER_VIRTIO || disk.StorageController == ghw.STORAGE_CONTROLLER_MMC {
c.logger.Debugf(" => Ignore: Virtual/multi-media storage controller - (found %s)\n", disk.StorageController.String())
continue
}
// Skip unknown storage controllers, not usually S.M.A.R.T compatible.
if disk.StorageController == ghw.STORAGE_CONTROLLER_UNKNOWN {
c.logger.Debugf(" => Ignore: Unknown storage controller - (found %s)\n", disk.StorageController.String())
continue
}
diskModel := models.Device{
WWN: disk.WWN,
Manufacturer: disk.Vendor,
ModelName: disk.Model,
InterfaceType: disk.StorageController.String(),
//InterfaceSpeed: string
SerialNumber: disk.SerialNumber,
Capacity: int64(disk.SizeBytes),
//Firmware string
//RotationSpeed int
DeviceName: disk.Name,
}
if len(diskModel.WWN) == 0 {
//(macOS and some other os's) do not provide a WWN, so we're going to fallback to
//diskname as identifier if WWN is not present
diskModel.WWN = disk.Name
}
approvedDisks = append(approvedDisks, diskModel)
}
return approvedDisks, nil
}
func (c *BaseCollector) getJson(url string, target interface{}) error {
r, err := httpClient.Get(url)
@ -118,29 +40,6 @@ func (c *BaseCollector) postJson(url string, body interface{}, target interface{
return json.NewDecoder(r.Body).Decode(target)
}
func (c *BaseCollector) ExecCmd(cmdName string, cmdArgs []string, workingDir string, environ []string) (string, error) {
cmd := exec.Command(cmdName, cmdArgs...)
var stdBuffer bytes.Buffer
mw := io.MultiWriter(os.Stdout, &stdBuffer)
cmd.Stdout = mw
cmd.Stderr = mw
if environ != nil {
cmd.Env = environ
}
if workingDir != "" && path.IsAbs(workingDir) {
cmd.Dir = workingDir
} else if workingDir != "" {
return "", errors.New("Working Directory must be an absolute path")
}
err := cmd.Run()
return stdBuffer.String(), err
}
func (c *BaseCollector) LogSmartctlExitCode(exitCode int) {
if exitCode&0x01 != 0 {
c.logger.Errorln("smartctl could not parse commandline")

@ -2,7 +2,10 @@ package collector
import (
"bytes"
"encoding/json"
"fmt"
"github.com/analogj/scrutiny/collector/pkg/common"
"github.com/analogj/scrutiny/collector/pkg/detect"
"github.com/analogj/scrutiny/collector/pkg/errors"
"github.com/analogj/scrutiny/collector/pkg/models"
"github.com/sirupsen/logrus"
@ -43,13 +46,18 @@ func (mc *MetricsCollector) Run() error {
apiEndpoint.Path = "/api/devices/register"
deviceRespWrapper := new(models.DeviceWrapper)
detectedStorageDevices, err := mc.DetectStorageDevices()
deviceDetector := detect.Detect{
Logger: mc.logger,
}
detectedStorageDevices, err := deviceDetector.Start()
if err != nil {
return err
}
mc.logger.Infoln("Sending detected devices to API, for filtering & validation")
mc.logger.Debugf("Detected devices: %v", detectedStorageDevices)
jsonObj, _ := json.Marshal(detectedStorageDevices)
mc.logger.Debugf("Detected devices: %v", string(jsonObj))
err = mc.postJson(apiEndpoint.String(), models.DeviceWrapper{
Data: detectedStorageDevices,
}, &deviceRespWrapper)
@ -93,7 +101,7 @@ func (mc *MetricsCollector) Collect(wg *sync.WaitGroup, deviceWWN string, device
defer wg.Done()
mc.logger.Infof("Collecting smartctl results for %s\n", deviceName)
result, err := mc.ExecCmd("smartctl", []string{"-a", "-j", fmt.Sprintf("/dev/%s", deviceName)}, "", nil)
result, err := common.ExecCmd("smartctl", []string{"-a", "-j", fmt.Sprintf("/dev/%s", deviceName)}, "", nil)
resultBytes := []byte(result)
if err != nil {
if exitError, ok := err.(*exec.ExitError); ok {

@ -0,0 +1,33 @@
package common
import (
"bytes"
"errors"
"io"
"os"
"os/exec"
"path"
)
func ExecCmd(cmdName string, cmdArgs []string, workingDir string, environ []string) (string, error) {
cmd := exec.Command(cmdName, cmdArgs...)
var stdBuffer bytes.Buffer
mw := io.MultiWriter(os.Stdout, &stdBuffer)
cmd.Stdout = mw
cmd.Stderr = mw
if environ != nil {
cmd.Env = environ
}
if workingDir != "" && path.IsAbs(workingDir) {
cmd.Dir = workingDir
} else if workingDir != "" {
return "", errors.New("Working Directory must be an absolute path")
}
err := cmd.Run()
return stdBuffer.String(), err
}

@ -1,7 +1,7 @@
package collector_test
package common_test
import (
"github.com/analogj/scrutiny/collector/pkg/collector"
"github.com/analogj/scrutiny/collector/pkg/common"
"github.com/stretchr/testify/require"
"os/exec"
"testing"
@ -11,10 +11,9 @@ func TestExecCmd(t *testing.T) {
t.Parallel()
//setup
bc := collector.BaseCollector{}
//test
result, err := bc.ExecCmd("echo", []string{"hello world"}, "", nil)
result, err := common.ExecCmd("echo", []string{"hello world"}, "", nil)
//assert
require.NoError(t, err)
@ -25,10 +24,9 @@ func TestExecCmd_Date(t *testing.T) {
t.Parallel()
//setup
bc := collector.BaseCollector{}
//test
_, err := bc.ExecCmd("date", []string{}, "", nil)
_, err := common.ExecCmd("date", []string{}, "", nil)
//assert
require.NoError(t, err)
@ -56,10 +54,9 @@ func TestExecCmd_InvalidCommand(t *testing.T) {
t.Parallel()
//setup
bc := collector.BaseCollector{}
//test
_, err := bc.ExecCmd("invalid_binary", []string{}, "", nil)
_, err := common.ExecCmd("invalid_binary", []string{}, "", nil)
//assert
_, castOk := err.(*exec.ExitError)

@ -0,0 +1,114 @@
package detect
import (
"encoding/json"
"fmt"
"github.com/analogj/scrutiny/collector/pkg/common"
"github.com/analogj/scrutiny/collector/pkg/models"
"github.com/analogj/scrutiny/webapp/backend/pkg/models/collector"
"github.com/denisbrodbeck/machineid"
"github.com/sirupsen/logrus"
"os"
"strings"
)
type Detect struct {
Logger *logrus.Entry
}
//private/common functions
// This function calls smartctl --scan which can be used to detect storage devices.
// It has a couple of issues however:
// - --scan does not return any results on mac
//
// To handle these issues, we have OS specific wrapper functions that update/modify these detected devices.
// models.Device returned from this function only contain the minimum data for smartctl to execute: device type and device name (device file).
func (d *Detect) smartctlScan() ([]models.Device, error) {
//we use smartctl to detect all the drives available.
detectedDeviceConnJson, err := common.ExecCmd("smartctl", []string{"--scan", "-j"}, "", os.Environ())
if err != nil {
d.Logger.Errorf("Error scanning for devices: %v", err)
return nil, err
}
var detectedDeviceConns models.Scan
err = json.Unmarshal([]byte(detectedDeviceConnJson), &detectedDeviceConns)
if err != nil {
d.Logger.Errorf("Error decoding detected devices: %v", err)
return nil, err
}
detectedDevices := []models.Device{}
for _, detectedDevice := range detectedDeviceConns.Devices {
detectedDevices = append(detectedDevices, models.Device{
DeviceType: detectedDevice.Type,
DeviceName: strings.TrimPrefix(detectedDevice.Name, d.devicePrefix()),
})
}
return detectedDevices, nil
}
//updates a device model with information from smartctl --scan
// It has a couple of issues however:
// - WWN is provided as component data, rather than a "string". We'll have to generate the WWN value ourselves
// - WWN from smartctl only provided for ATA protocol drives, NVMe and SCSI drives do not include WWN.
func (d *Detect) smartCtlInfo(device *models.Device) error {
args := []string{"--info", "-j"}
if len(device.DeviceType) > 0 {
args = append(args, "-d", device.DeviceType)
}
args = append(args, fmt.Sprintf("%s%s", d.devicePrefix(), device.DeviceName))
availableDeviceInfoJson, err := common.ExecCmd("smartctl", args, "", os.Environ())
if err != nil {
d.Logger.Errorf("Could not retrieve device information for %s: %v", device.DeviceName, err)
return err
}
var availableDeviceInfo collector.SmartInfo
err = json.Unmarshal([]byte(availableDeviceInfoJson), &availableDeviceInfo)
if err != nil {
d.Logger.Errorf("Could not decode device information for %s: %v", device.DeviceName, err)
return err
}
//DeviceType and DeviceName are already populated.
//WWN
device.ModelName = availableDeviceInfo.ModelName
//InterfaceType:
device.InterfaceSpeed = availableDeviceInfo.InterfaceSpeed.Current.String
device.SerialNumber = availableDeviceInfo.SerialNumber
device.Firmware = availableDeviceInfo.FirmwareVersion
device.RotationSpeed = availableDeviceInfo.RotationRate
device.Capacity = availableDeviceInfo.UserCapacity.Bytes
device.FormFactor = availableDeviceInfo.FormFactor.Name
device.DeviceProtocol = availableDeviceInfo.Device.Protocol
if len(availableDeviceInfo.Vendor) > 0 {
device.Manufacturer = availableDeviceInfo.Vendor
}
//populate WWN is possible if present
if availableDeviceInfo.Wwn.Naa != 0 { //valid values are 1-6 (5 is what we handle correctly)
d.Logger.Info("Generating WWN")
wwn := Wwn{
Naa: availableDeviceInfo.Wwn.Naa,
Oui: availableDeviceInfo.Wwn.Oui,
Id: availableDeviceInfo.Wwn.ID,
}
device.WWN = wwn.ToString()
} else {
d.Logger.Info("Using WWN Fallback")
d.wwnFallback(device)
}
return nil
}
//uses https://github.com/denisbrodbeck/machineid to get a OS specific unique machine ID.
func (d *Detect) getMachineId() (string, error) {
return machineid.ProtectedID("scrutiny")
}

@ -0,0 +1,106 @@
package detect
import (
"github.com/analogj/scrutiny/collector/pkg/models"
"github.com/jaypipes/ghw"
"strings"
)
func (d *Detect) devicePrefix() string {
return "/dev/"
}
func (d *Detect) Start() ([]models.Device, error) {
// call the base/common functionality to get a list of devicess
detectedDevices, err := d.smartctlScan()
if err != nil {
return nil, err
}
//smartctl --scan doesn't seem to detect mac nvme drives, lets see if we can detect them manually.
missingDevices, err := d.findMissingDevices(detectedDevices) //we dont care about the error here, just continue retrieving device info.
if err == nil {
detectedDevices = append(detectedDevices, missingDevices...)
}
//inflate device info for detected devices.
for ndx, _ := range detectedDevices {
d.smartCtlInfo(&detectedDevices[ndx]) //ignore errors.
}
return detectedDevices, nil
}
func (d *Detect) findMissingDevices(detectedDevices []models.Device) ([]models.Device, error) {
missingDevices := []models.Device{}
block, err := ghw.Block()
if err != nil {
d.Logger.Errorf("Error getting block storage info: %v", err)
return nil, err
}
for _, disk := range block.Disks {
// ignore optical drives and floppy disks
if disk.DriveType == ghw.DRIVE_TYPE_FDD || disk.DriveType == ghw.DRIVE_TYPE_ODD {
d.Logger.Debugf(" => Ignore: Optical or floppy disk - (found %s)\n", disk.DriveType.String())
continue
}
// ignore removable disks
if disk.IsRemovable {
d.Logger.Debugf(" => Ignore: Removable disk (%v)\n", disk.IsRemovable)
continue
}
// ignore virtual disks & mobile phone storage devices
if disk.StorageController == ghw.STORAGE_CONTROLLER_VIRTIO || disk.StorageController == ghw.STORAGE_CONTROLLER_MMC {
d.Logger.Debugf(" => Ignore: Virtual/multi-media storage controller - (found %s)\n", disk.StorageController.String())
continue
}
// Skip unknown storage controllers, not usually S.M.A.R.T compatible.
if disk.StorageController == ghw.STORAGE_CONTROLLER_UNKNOWN {
d.Logger.Debugf(" => Ignore: Unknown storage controller - (found %s)\n", disk.StorageController.String())
continue
}
//check if device is already detected.
alreadyDetected := false
diskName := strings.TrimPrefix(disk.Name, d.devicePrefix())
for _, detectedDevice := range detectedDevices {
if detectedDevice.DeviceName == diskName {
alreadyDetected = true
break
}
}
if !alreadyDetected {
missingDevices = append(missingDevices, models.Device{
DeviceName: diskName,
DeviceType: "",
})
}
}
return missingDevices, nil
}
//WWN values NVMe and SCSI
func (d *Detect) wwnFallback(detectedDevice *models.Device) {
block, err := ghw.Block()
if err == nil {
for _, disk := range block.Disks {
if disk.Name == detectedDevice.DeviceName {
detectedDevice.WWN = disk.WWN
break
}
}
}
//no WWN found, or could not open Block devices. Either way, fallback to serial number
if len(detectedDevice.WWN) == 0 {
detectedDevice.WWN = detectedDevice.SerialNumber
}
}

@ -0,0 +1,38 @@
package detect
func (d *Detect) devicePrefix() string {
return "/dev/"
}
func (d *Detect) Start() ([]models.Device, error) {
// call the base/common functionality to get a list of devices
detectedDevices, err := d.smartctlScan()
if err != nil {
return nil, err
}
//inflate device info for detected devices.
for ndx, _ := range detectedDevices {
d.smartCtlInfo(&detectedDevices[ndx]) //ignore errors.
}
return detectedDevices, nil
}
//WWN values NVMe and SCSI
func (d *Detect) wwnFallback(detectedDevice *models.Device) {
block, err := ghw.Block()
if err == nil {
for _, disk := range block.Disks {
if disk.Name == detectedDevice.DeviceName {
detectedDevice.WWN = disk.WWN
break
}
}
}
//no WWN found, or could not open Block devices. Either way, fallback to serial number
if len(detectedDevice.WWN) == 0 {
detectedDevice.WWN = detectedDevice.SerialNumber
}
}

@ -0,0 +1,29 @@
package detect
func (d *Detect) devicePrefix() string {
return ""
}
func (d *Detect) Start() ([]models.Device, error) {
// call the base/common functionality to get a list of devices
detectedDevices, err := d.smartctlScan()
if err != nil {
return nil, err
}
//inflate device info for detected devices.
for ndx, _ := range detectedDevices {
d.smartCtlInfo(&detectedDevices[ndx]) //ignore errors.
}
return detectedDevices, nil
}
//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
}
}

@ -0,0 +1,58 @@
package detect
import (
"fmt"
)
type Wwn struct {
Naa uint64 `json:"naa"`
Oui uint64 `json:"oui"`
Id uint64 `json:"id"`
VendorCode string `json:"vendor_code"`
}
// this is an incredibly basic converter, that only works for "Registered" IEEE format - NAA5
// https://standards.ieee.org/content/dam/ieee-standards/standards/web/documents/tutorials/fibre.pdf
// references:
// - https://metacpan.org/pod/Device::WWN
// - https://en.wikipedia.org/wiki/World_Wide_Name
// - https://storagemeat.blogspot.com/2012/08/decoding-wwids-or-how-to-tell-whats-what.html
// - https://bryanchain.com/2016/01/20/breaking-down-an-naa-id-world-wide-name/
/*
+----------+---+---+---+---+---+---+---+---+
| Byte/Bit | 7 | 6 | 5 | 4 | 3 | 2 | 1 | 0 |
+----------+---+---+---+---+---+---+---+---+
| 0 | NAA (5h) | (MSB) |
+----------+---------------+ +
| 1 | |
+----------+ IEEE OUI |
| 2 | |
+----------+ +---------------+
| 3 | (LSB) | (MSB) |
+----------+---------------+ +
| 4 | |
| | |
+----------+ |
| 5 | Vendor ID |
+----------+ |
| 6 | |
+----------+ |
| 7 | (LSB) |
+----------+-------------------------------+
*/
func (wwn *Wwn) ToString() string {
var wwnBuffer uint64
wwnBuffer = wwn.Id //start with vendor ID
wwnBuffer += (wwn.Oui << 36) //add left-shifted OUI
wwnBuffer += (wwn.Naa << 60) //NAA is a number from 1-6, so decimal == hex.
//TODO: may need to support additional versions in the future.
return fmt.Sprintf("%#x", wwnBuffer)
}

@ -0,0 +1,35 @@
package detect_test
import (
"fmt"
"github.com/analogj/scrutiny/collector/pkg/detect"
"github.com/stretchr/testify/require"
"testing"
)
func TestWwn_FromStringTable(t *testing.T) {
//setup
var tests = []struct {
wwnStr string
wwn detect.Wwn
}{
{"0x5002538e40a22954", detect.Wwn{Naa: 5, Oui: 9528, Id: 61213911380}}, //sda
{"0x5000cca264eb01d7", detect.Wwn{Naa: 5, Oui: 3274, Id: 10283057623}}, //sdb
{"0x5000cca264ec3183", detect.Wwn{Naa: 5, Oui: 3274, Id: 10283135363}}, //sdc
{"0x5000cca252c859cc", detect.Wwn{Naa: 5, Oui: 3274, Id: 9978796492}}, //sdd
{"0x50014ee20b2a72a9", detect.Wwn{Naa: 5, Oui: 5358, Id: 8777265833}}, //sde
{"0x5000cca264ebc248", detect.Wwn{Naa: 5, Oui: 3274, Id: 10283106888}}, //sdf
{"0x5000c500673e6b5f", detect.Wwn{Naa: 5, Oui: 3152, Id: 1732143967}}, //sdg
}
//test
for _, tt := range tests {
testname := fmt.Sprintf("%s", tt.wwnStr)
t.Run(testname, func(t *testing.T) {
str := tt.wwn.ToString()
require.Equal(t, tt.wwnStr, str)
})
}
}

@ -3,9 +3,10 @@ module github.com/analogj/scrutiny
go 1.13
require (
github.com/AnalogJ/go-util v0.0.0-20200905200945-3b93d31215ae
github.com/AnalogJ/go-util v0.0.0-20200905200945-3b93d31215ae // indirect
github.com/analogj/go-util v0.0.0-20190301173314-5295e364eb14
github.com/containrrr/shoutrrr v0.0.0-20200828202222-1da53231b05a
github.com/denisbrodbeck/machineid v1.0.1
github.com/fatih/color v1.9.0
github.com/gin-gonic/gin v1.6.3
github.com/golang/mock v1.4.3

@ -50,6 +50,8 @@ github.com/cpuguy83/go-md2man/v2 v2.0.0/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsr
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/denisbrodbeck/machineid v1.0.1 h1:geKr9qtkB876mXguW2X6TU4ZynleN6ezuMSRhl4D7AQ=
github.com/denisbrodbeck/machineid v1.0.1/go.mod h1:dJUwb7PTidGDeYyUBmXZ2GphQBbjJCrnectwCyxcUSI=
github.com/denisenkom/go-mssqldb v0.0.0-20191124224453-732737034ffd/go.mod h1:xbL0rPBG9cCiLr28tMa8zpbdarY27NDyej4t/EjAShU=
github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ=
github.com/dgryski/go-sip13 v0.0.0-20181026042036-e10d5fee7954/go.mod h1:vAd38F8PWV+bWy6jNmig1y/TA+kYO4g3RSRF0IAv0no=

@ -23,9 +23,9 @@ type SmartInfo struct {
ModelName string `json:"model_name"`
SerialNumber string `json:"serial_number"`
Wwn struct {
Naa int `json:"naa"`
Oui int `json:"oui"`
ID int64 `json:"id"`
Naa uint64 `json:"naa"`
Oui uint64 `json:"oui"`
ID uint64 `json:"id"`
} `json:"wwn"`
FirmwareVersion string `json:"firmware_version"`
UserCapacity struct {

Loading…
Cancel
Save