From 96196a353cbe84e01303c26dfc3d62831264c312 Mon Sep 17 00:00:00 2001 From: henrygd Date: Wed, 10 Dec 2025 14:09:20 -0500 Subject: [PATCH] smart: fallback to nvme namespace path if base controller path fails (#1504) --- agent/smart.go | 50 +++++++++++++++++++++++++++++++++++++++++++++ agent/smart_test.go | 33 ++++++++++++++++++++++++++++++ 2 files changed, 83 insertions(+) diff --git a/agent/smart.go b/agent/smart.go index b448869b..d1e003d6 100644 --- a/agent/smart.go +++ b/agent/smart.go @@ -10,6 +10,7 @@ import ( "fmt" "os" "os/exec" + "path/filepath" "runtime" "strconv" "strings" @@ -454,6 +455,34 @@ func (sm *SmartManager) CollectSmart(deviceInfo *DeviceInfo) error { hasValidData := sm.parseSmartOutput(deviceInfo, output) + // If NVMe controller path failed, try namespace path as fallback. + // NVMe controllers (/dev/nvme0) don't always support SMART queries. See github.com/henrygd/beszel/issues/1504 + if !hasValidData && err != nil && isNvmeControllerPath(deviceInfo.Name) { + controllerPath := deviceInfo.Name + namespacePath := controllerPath + "n1" + if !sm.isExcludedDevice(namespacePath) { + deviceInfo.Name = namespacePath + + ctx3, cancel3 := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel3() + args = sm.smartctlArgs(deviceInfo, false) + cmd = exec.CommandContext(ctx3, sm.binPath, args...) + output, err = cmd.CombinedOutput() + hasValidData = sm.parseSmartOutput(deviceInfo, output) + + // Auto-exclude the controller path so future scans don't re-add it + if hasValidData { + sm.Lock() + if sm.excludedDevices == nil { + sm.excludedDevices = make(map[string]struct{}) + } + sm.excludedDevices[controllerPath] = struct{}{} + sm.Unlock() + slog.Debug("auto-excluded NVMe controller path", "path", controllerPath) + } + } + } + if !hasValidData { if err != nil { slog.Info("smartctl failed", "device", deviceInfo.Name, "err", err) @@ -957,6 +986,27 @@ func (sm *SmartManager) detectSmartctl() (string, error) { return "", errors.New("smartctl not found") } +// isNvmeControllerPath checks if the path matches an NVMe controller pattern +// like /dev/nvme0, /dev/nvme1, etc. (without namespace suffix like n1) +func isNvmeControllerPath(path string) bool { + base := filepath.Base(path) + if !strings.HasPrefix(base, "nvme") { + return false + } + suffix := strings.TrimPrefix(base, "nvme") + if suffix == "" { + return false + } + // Controller paths are just "nvme" + digits (e.g., nvme0, nvme1) + // Namespace paths have "n" after the controller number (e.g., nvme0n1) + for _, c := range suffix { + if c < '0' || c > '9' { + return false + } + } + return true +} + // NewSmartManager creates and initializes a new SmartManager func NewSmartManager() (*SmartManager, error) { sm := &SmartManager{ diff --git a/agent/smart_test.go b/agent/smart_test.go index 8b983cbe..08d5d467 100644 --- a/agent/smart_test.go +++ b/agent/smart_test.go @@ -780,3 +780,36 @@ func TestFilterExcludedDevices(t *testing.T) { }) } } + +func TestIsNvmeControllerPath(t *testing.T) { + tests := []struct { + path string + expected bool + }{ + // Controller paths (should return true) + {"/dev/nvme0", true}, + {"/dev/nvme1", true}, + {"/dev/nvme10", true}, + {"nvme0", true}, + + // Namespace paths (should return false) + {"/dev/nvme0n1", false}, + {"/dev/nvme1n1", false}, + {"/dev/nvme0n1p1", false}, + {"nvme0n1", false}, + + // Non-NVMe paths (should return false) + {"/dev/sda", false}, + {"/dev/sda1", false}, + {"/dev/hda", false}, + {"", false}, + {"/dev/nvme", false}, + } + + for _, tt := range tests { + t.Run(tt.path, func(t *testing.T) { + result := isNvmeControllerPath(tt.path) + assert.Equal(t, tt.expected, result, "path: %s", tt.path) + }) + } +}