diff --git a/agent/smart.go b/agent/smart.go index a81c894c..6626d331 100644 --- a/agent/smart.go +++ b/agent/smart.go @@ -24,11 +24,12 @@ import ( // SmartManager manages data collection for SMART devices type SmartManager struct { sync.Mutex - SmartDataMap map[string]*smart.SmartData - SmartDevices []*DeviceInfo - refreshMutex sync.Mutex - lastScanTime time.Time - binPath string + SmartDataMap map[string]*smart.SmartData + SmartDevices []*DeviceInfo + refreshMutex sync.Mutex + lastScanTime time.Time + binPath string + excludedDevices map[string]struct{} } type scanOutput struct { @@ -185,6 +186,7 @@ func (sm *SmartManager) ScanDevices(force bool) error { } finalDevices := mergeDeviceLists(currentDevices, scannedDevices, configuredDevices) + finalDevices = sm.filterExcludedDevices(finalDevices) sm.updateSmartDevices(finalDevices) if len(finalDevices) == 0 { @@ -232,6 +234,47 @@ func (sm *SmartManager) parseConfiguredDevices(config string) ([]*DeviceInfo, er return devices, nil } +func (sm *SmartManager) refreshExcludedDevices() { + rawValue, _ := GetEnv("EXCLUDE_SMART") + sm.excludedDevices = make(map[string]struct{}) + + for entry := range strings.SplitSeq(rawValue, ",") { + device := strings.TrimSpace(entry) + if device == "" { + continue + } + sm.excludedDevices[device] = struct{}{} + } +} + +func (sm *SmartManager) isExcludedDevice(deviceName string) bool { + _, exists := sm.excludedDevices[deviceName] + return exists +} + +func (sm *SmartManager) filterExcludedDevices(devices []*DeviceInfo) []*DeviceInfo { + if devices == nil { + return []*DeviceInfo{} + } + + excluded := sm.excludedDevices + if len(excluded) == 0 { + return devices + } + + filtered := make([]*DeviceInfo, 0, len(devices)) + for _, device := range devices { + if device == nil || device.Name == "" { + continue + } + if _, skip := excluded[device.Name]; skip { + continue + } + filtered = append(filtered, device) + } + return filtered +} + // detectSmartOutputType inspects sections that are unique to each smartctl // JSON schema (NVMe, ATA/SATA, SCSI) to determine which parser should be used // when the reported device type is ambiguous or missing. @@ -378,6 +421,10 @@ func (sm *SmartManager) parseSmartOutput(deviceInfo *DeviceInfo, output []byte) // Uses -n standby to avoid waking up sleeping disks, but bypasses standby mode // for initial data collection when no cached data exists func (sm *SmartManager) CollectSmart(deviceInfo *DeviceInfo) error { + if deviceInfo != nil && sm.isExcludedDevice(deviceInfo.Name) { + return errNoValidSmartData + } + // slog.Info("collecting SMART data", "device", deviceInfo.Name, "type", deviceInfo.Type, "has_existing_data", sm.hasDataForDevice(deviceInfo.Name)) // Check if we have any existing data for this device @@ -409,10 +456,10 @@ func (sm *SmartManager) CollectSmart(deviceInfo *DeviceInfo) error { if !hasValidData { if err != nil { - slog.Debug("smartctl failed", "device", deviceInfo.Name, "err", err) + slog.Info("smartctl failed", "device", deviceInfo.Name, "err", err) return err } - slog.Debug("no valid SMART data found", "device", deviceInfo.Name) + slog.Info("no valid SMART data found", "device", deviceInfo.Name) return errNoValidSmartData } @@ -915,6 +962,7 @@ func NewSmartManager() (*SmartManager, error) { sm := &SmartManager{ SmartDataMap: make(map[string]*smart.SmartData), } + sm.refreshExcludedDevices() path, err := sm.detectSmartctl() if err != nil { slog.Debug(err.Error()) diff --git a/agent/smart_test.go b/agent/smart_test.go index 98f6a926..8b983cbe 100644 --- a/agent/smart_test.go +++ b/agent/smart_test.go @@ -588,3 +588,195 @@ func TestIsVirtualDeviceScsi(t *testing.T) { }) } } + +func TestRefreshExcludedDevices(t *testing.T) { + tests := []struct { + name string + envValue string + expectedDevs map[string]struct{} + }{ + { + name: "empty env", + envValue: "", + expectedDevs: map[string]struct{}{}, + }, + { + name: "single device", + envValue: "/dev/sda", + expectedDevs: map[string]struct{}{ + "/dev/sda": {}, + }, + }, + { + name: "multiple devices", + envValue: "/dev/sda,/dev/sdb,/dev/nvme0", + expectedDevs: map[string]struct{}{ + "/dev/sda": {}, + "/dev/sdb": {}, + "/dev/nvme0": {}, + }, + }, + { + name: "devices with whitespace", + envValue: " /dev/sda , /dev/sdb , /dev/nvme0 ", + expectedDevs: map[string]struct{}{ + "/dev/sda": {}, + "/dev/sdb": {}, + "/dev/nvme0": {}, + }, + }, + { + name: "duplicate devices", + envValue: "/dev/sda,/dev/sdb,/dev/sda", + expectedDevs: map[string]struct{}{ + "/dev/sda": {}, + "/dev/sdb": {}, + }, + }, + { + name: "empty entries and whitespace", + envValue: "/dev/sda,, /dev/sdb , , ", + expectedDevs: map[string]struct{}{ + "/dev/sda": {}, + "/dev/sdb": {}, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.envValue != "" { + t.Setenv("EXCLUDE_SMART", tt.envValue) + } else { + // Ensure env var is not set for empty test + os.Unsetenv("EXCLUDE_SMART") + } + + sm := &SmartManager{} + sm.refreshExcludedDevices() + + assert.Equal(t, tt.expectedDevs, sm.excludedDevices) + }) + } +} + +func TestIsExcludedDevice(t *testing.T) { + sm := &SmartManager{ + excludedDevices: map[string]struct{}{ + "/dev/sda": {}, + "/dev/nvme0": {}, + }, + } + + tests := []struct { + name string + deviceName string + expectedBool bool + }{ + {"excluded device sda", "/dev/sda", true}, + {"excluded device nvme0", "/dev/nvme0", true}, + {"non-excluded device sdb", "/dev/sdb", false}, + {"non-excluded device nvme1", "/dev/nvme1", false}, + {"empty device name", "", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := sm.isExcludedDevice(tt.deviceName) + assert.Equal(t, tt.expectedBool, result) + }) + } +} + +func TestFilterExcludedDevices(t *testing.T) { + tests := []struct { + name string + excludedDevs map[string]struct{} + inputDevices []*DeviceInfo + expectedDevs []*DeviceInfo + expectedLength int + }{ + { + name: "no exclusions", + excludedDevs: map[string]struct{}{}, + inputDevices: []*DeviceInfo{ + {Name: "/dev/sda"}, + {Name: "/dev/sdb"}, + {Name: "/dev/nvme0"}, + }, + expectedDevs: []*DeviceInfo{ + {Name: "/dev/sda"}, + {Name: "/dev/sdb"}, + {Name: "/dev/nvme0"}, + }, + expectedLength: 3, + }, + { + name: "some devices excluded", + excludedDevs: map[string]struct{}{ + "/dev/sda": {}, + "/dev/nvme0": {}, + }, + inputDevices: []*DeviceInfo{ + {Name: "/dev/sda"}, + {Name: "/dev/sdb"}, + {Name: "/dev/nvme0"}, + {Name: "/dev/nvme1"}, + }, + expectedDevs: []*DeviceInfo{ + {Name: "/dev/sdb"}, + {Name: "/dev/nvme1"}, + }, + expectedLength: 2, + }, + { + name: "all devices excluded", + excludedDevs: map[string]struct{}{ + "/dev/sda": {}, + "/dev/sdb": {}, + }, + inputDevices: []*DeviceInfo{ + {Name: "/dev/sda"}, + {Name: "/dev/sdb"}, + }, + expectedDevs: []*DeviceInfo{}, + expectedLength: 0, + }, + { + name: "nil devices", + excludedDevs: map[string]struct{}{}, + inputDevices: nil, + expectedDevs: []*DeviceInfo{}, + expectedLength: 0, + }, + { + name: "filter nil and empty name devices", + excludedDevs: map[string]struct{}{ + "/dev/sda": {}, + }, + inputDevices: []*DeviceInfo{ + {Name: "/dev/sda"}, + nil, + {Name: ""}, + {Name: "/dev/sdb"}, + }, + expectedDevs: []*DeviceInfo{ + {Name: "/dev/sdb"}, + }, + expectedLength: 1, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + sm := &SmartManager{ + excludedDevices: tt.excludedDevs, + } + + result := sm.filterExcludedDevices(tt.inputDevices) + + assert.Len(t, result, tt.expectedLength) + assert.Equal(t, tt.expectedDevs, result) + }) + } +}