diff --git a/sysfs/class_nvme.go b/sysfs/class_nvme.go index 650416e7..b5b021b8 100644 --- a/sysfs/class_nvme.go +++ b/sysfs/class_nvme.go @@ -19,20 +19,35 @@ import ( "fmt" "os" "path/filepath" + "regexp" + "strconv" "github.com/prometheus/procfs/internal/util" ) const nvmeClassPath = "class/nvme" +// NVMeNamespace contains info from files in /sys/class/nvme//. +type NVMeNamespace struct { + ID string // namespace ID extracted from directory name + UsedBlocks uint64 // from nuse file (blocks used) + SizeBlocks uint64 // from size file (total blocks) + LogicalBlockSize uint64 // from queue/logical_block_size file + ANAState string // from ana_state file + UsedBytes uint64 // calculated: UsedBlocks * LogicalBlockSize + SizeBytes uint64 // calculated: SizeBlocks * LogicalBlockSize + CapacityBytes uint64 // calculated: SizeBlocks * LogicalBlockSize +} + // NVMeDevice contains info from files in /sys/class/nvme for a single NVMe device. type NVMeDevice struct { Name string - Serial string // /sys/class/nvme//serial - Model string // /sys/class/nvme//model - State string // /sys/class/nvme//state - FirmwareRevision string // /sys/class/nvme//firmware_rev - ControllerID string // /sys/class/nvme//cntlid + Serial string // /sys/class/nvme//serial + Model string // /sys/class/nvme//model + State string // /sys/class/nvme//state + FirmwareRevision string // /sys/class/nvme//firmware_rev + ControllerID string // /sys/class/nvme//cntlid + Namespaces []NVMeNamespace // NVMe namespaces for this device } // NVMeClass is a collection of every NVMe device in /sys/class/nvme. @@ -67,6 +82,7 @@ func (fs FS) parseNVMeDevice(name string) (*NVMeDevice, error) { path := fs.sys.Path(nvmeClassPath, name) device := NVMeDevice{Name: name} + // Parse device-level attributes for _, f := range [...]string{"firmware_rev", "model", "serial", "state", "cntlid"} { name := filepath.Join(path, f) value, err := util.SysReadFile(name) @@ -88,5 +104,69 @@ func (fs FS) parseNVMeDevice(name string) (*NVMeDevice, error) { } } + // Parse namespaces - read directory and filter using regex + dirs, err := os.ReadDir(path) + if err != nil { + return nil, fmt.Errorf("failed to list NVMe namespaces at %q: %w", path, err) + } + + var namespaces []NVMeNamespace + var re = regexp.MustCompile(`nvme\d+c\d+n(\d+)`) + + for _, d := range dirs { + // Use regex to identify namespace directories and extract namespace ID + match := re.FindStringSubmatch(d.Name()) + if len(match) < 2 { + // Skip if not a namespace directory + continue + } + nsid := match[1] + namespacePath := filepath.Join(path, d.Name()) + + namespace := NVMeNamespace{ + ID: nsid, + ANAState: "unknown", // Default value + } + + // Parse namespace attributes using the same approach as device attributes + for _, f := range [...]string{"nuse", "size", "queue/logical_block_size", "ana_state"} { + filePath := filepath.Join(namespacePath, f) + value, err := util.SysReadFile(filePath) + if err != nil { + if f == "ana_state" { + // ana_state may not exist, skip silently + continue + } + return nil, fmt.Errorf("failed to read file %q: %w", filePath, err) + } + + switch f { + case "nuse": + if val, parseErr := strconv.ParseUint(value, 10, 64); parseErr == nil { + namespace.UsedBlocks = val + } + case "size": + if val, parseErr := strconv.ParseUint(value, 10, 64); parseErr == nil { + namespace.SizeBlocks = val + } + case "queue/logical_block_size": + if val, parseErr := strconv.ParseUint(value, 10, 64); parseErr == nil { + namespace.LogicalBlockSize = val + } + case "ana_state": + namespace.ANAState = value + } + } + + // Calculate derived values + namespace.UsedBytes = namespace.UsedBlocks * namespace.LogicalBlockSize + namespace.SizeBytes = namespace.SizeBlocks * namespace.LogicalBlockSize + namespace.CapacityBytes = namespace.SizeBlocks * namespace.LogicalBlockSize + + namespaces = append(namespaces, namespace) + } + + device.Namespaces = namespaces + return &device, nil } diff --git a/sysfs/class_nvme_test.go b/sysfs/class_nvme_test.go index 10a7a678..9c06cdb0 100644 --- a/sysfs/class_nvme_test.go +++ b/sysfs/class_nvme_test.go @@ -16,12 +16,20 @@ package sysfs import ( + "os" + "path/filepath" "testing" "github.com/google/go-cmp/cmp" ) func TestNVMeClass(t *testing.T) { + // Check if test fixtures exist + if _, err := os.Stat(sysTestFixtures); os.IsNotExist(err) { + t.Skip("Test fixtures not available, skipping NVMe class tests") + return + } + fs, err := NewFS(sysTestFixtures) if err != nil { t.Fatal(err) @@ -40,6 +48,18 @@ func TestNVMeClass(t *testing.T) { Serial: "S680HF8N190894I", State: "live", ControllerID: "1997", + Namespaces: []NVMeNamespace{ + { + ID: "0", + UsedBlocks: 488281250, + SizeBlocks: 3906250000, + LogicalBlockSize: 4096, + ANAState: "optimized", + UsedBytes: 2000000000000, + SizeBytes: 16000000000000, + CapacityBytes: 16000000000000, + }, + }, }, } @@ -47,3 +67,348 @@ func TestNVMeClass(t *testing.T) { t.Fatalf("unexpected NVMe class (-want +got):\n%s", diff) } } + +func TestNVMeNamespaceParsingWithMockData(t *testing.T) { + // Create a temporary directory structure for testing namespace parsing + tempDir := t.TempDir() + + // Create mock NVMe device directory structure + deviceDir := filepath.Join(tempDir, "class", "nvme", "nvme0") + err := os.MkdirAll(deviceDir, 0o755) + if err != nil { + t.Fatal(err) + } + + // Create device files + deviceFiles := map[string]string{ + "firmware_rev": "1B2QEXP7", + "model": "Samsung SSD 970 PRO 512GB", + "serial": "S680HF8N190894I", + "state": "live", + "cntlid": "1997", + } + + for filename, content := range deviceFiles { + err := os.WriteFile(filepath.Join(deviceDir, filename), []byte(content), 0o644) + if err != nil { + t.Fatal(err) + } + } + + // Create mock namespace directory and files + namespaceDir := filepath.Join(deviceDir, "nvme0c0n1") + err = os.MkdirAll(filepath.Join(namespaceDir, "queue"), 0o755) + if err != nil { + t.Fatal(err) + } + + namespaceFiles := map[string]string{ + "nuse": "123456", + "size": "1000215216", + "ana_state": "optimized", + "queue/logical_block_size": "512", + } + + for filename, content := range namespaceFiles { + filePath := filepath.Join(namespaceDir, filename) + err := os.WriteFile(filePath, []byte(content), 0o644) + if err != nil { + t.Fatal(err) + } + } + + // Create filesystem and test + fs, err := NewFS(tempDir) + if err != nil { + t.Fatal(err) + } + + got, err := fs.NVMeClass() + if err != nil { + t.Fatal(err) + } + + // Verify the device was parsed correctly + if len(got) != 1 { + t.Fatalf("Expected 1 device, got %d", len(got)) + } + + device, exists := got["nvme0"] + if !exists { + t.Fatal("Expected nvme0 device not found") + } + + // Verify device properties + if device.Name != "nvme0" { + t.Errorf("Expected device name nvme0, got %s", device.Name) + } + + if device.Model != "Samsung SSD 970 PRO 512GB" { + t.Errorf("Expected model 'Samsung SSD 970 PRO 512GB', got %s", device.Model) + } + + // Verify namespace was parsed correctly + if len(device.Namespaces) != 1 { + t.Fatalf("Expected 1 namespace, got %d", len(device.Namespaces)) + } + + namespace := device.Namespaces[0] + expectedNamespace := NVMeNamespace{ + ID: "1", + UsedBlocks: 123456, + SizeBlocks: 1000215216, + LogicalBlockSize: 512, + ANAState: "optimized", + UsedBytes: 123456 * 512, + SizeBytes: 1000215216 * 512, + CapacityBytes: 1000215216 * 512, + } + + if diff := cmp.Diff(expectedNamespace, namespace); diff != "" { + t.Fatalf("unexpected NVMe namespace (-want +got):\n%s", diff) + } +} + +func TestNVMeMultipleNamespaces(t *testing.T) { + // Create a temporary directory structure for testing multiple namespaces + tempDir := t.TempDir() + + // Create mock NVMe device directory structure + deviceDir := filepath.Join(tempDir, "class", "nvme", "nvme1") + err := os.MkdirAll(deviceDir, 0o755) + if err != nil { + t.Fatal(err) + } + + // Create device files + deviceFiles := map[string]string{ + "firmware_rev": "2C3DEXP8", + "model": "Test NVMe SSD 1TB", + "serial": "TEST123456789", + "state": "live", + "cntlid": "2048", + } + + for filename, content := range deviceFiles { + err := os.WriteFile(filepath.Join(deviceDir, filename), []byte(content), 0o644) + if err != nil { + t.Fatal(err) + } + } + + // Create multiple mock namespace directories + namespaces := []struct { + dirName string + nsID string + nuse string + size string + anaState string + blockSize string + }{ + {"nvme1c0n1", "1", "100000", "2000000000", "optimized", "4096"}, + {"nvme1c0n2", "2", "50000", "1000000000", "active", "512"}, + } + + for _, ns := range namespaces { + namespaceDir := filepath.Join(deviceDir, ns.dirName) + err = os.MkdirAll(filepath.Join(namespaceDir, "queue"), 0o755) + if err != nil { + t.Fatal(err) + } + + namespaceFiles := map[string]string{ + "nuse": ns.nuse, + "size": ns.size, + "ana_state": ns.anaState, + "queue/logical_block_size": ns.blockSize, + } + + for filename, content := range namespaceFiles { + filePath := filepath.Join(namespaceDir, filename) + err := os.WriteFile(filePath, []byte(content), 0o644) + if err != nil { + t.Fatal(err) + } + } + } + + // Create filesystem and test + fs, err := NewFS(tempDir) + if err != nil { + t.Fatal(err) + } + + got, err := fs.NVMeClass() + if err != nil { + t.Fatal(err) + } + + // Verify the device was parsed correctly + if len(got) != 1 { + t.Fatalf("Expected 1 device, got %d", len(got)) + } + + device, exists := got["nvme1"] + if !exists { + t.Fatal("Expected nvme1 device not found") + } + + // Verify both namespaces were parsed correctly + if len(device.Namespaces) != 2 { + t.Fatalf("Expected 2 namespaces, got %d", len(device.Namespaces)) + } + + // Find namespace 1 + var ns1, ns2 *NVMeNamespace +findNamespaces: + for i := range device.Namespaces { + switch device.Namespaces[i].ID { + case "1": + ns1 = &device.Namespaces[i] + if ns2 != nil { + break findNamespaces + } + case "2": + ns2 = &device.Namespaces[i] + if ns1 != nil { + break findNamespaces + } + } + } + + if ns1 == nil { + t.Fatal("Namespace 1 not found") + } + if ns2 == nil { + t.Fatal("Namespace 2 not found") + } + + // Verify namespace 1 + expectedNS1 := NVMeNamespace{ + ID: "1", + UsedBlocks: 100000, + SizeBlocks: 2000000000, + LogicalBlockSize: 4096, + ANAState: "optimized", + UsedBytes: 100000 * 4096, + SizeBytes: 2000000000 * 4096, + CapacityBytes: 2000000000 * 4096, + } + + if diff := cmp.Diff(expectedNS1, *ns1); diff != "" { + t.Errorf("unexpected NVMe namespace 1 (-want +got):\n%s", diff) + } + + // Verify namespace 2 + expectedNS2 := NVMeNamespace{ + ID: "2", + UsedBlocks: 50000, + SizeBlocks: 1000000000, + LogicalBlockSize: 512, + ANAState: "active", + UsedBytes: 50000 * 512, + SizeBytes: 1000000000 * 512, + CapacityBytes: 1000000000 * 512, + } + + if diff := cmp.Diff(expectedNS2, *ns2); diff != "" { + t.Errorf("unexpected NVMe namespace 2 (-want +got):\n%s", diff) + } +} + +func TestNVMeNamespaceMissingFiles(t *testing.T) { + // Test graceful handling of missing namespace files + tempDir := t.TempDir() + + // Create mock NVMe device directory structure + deviceDir := filepath.Join(tempDir, "class", "nvme", "nvme2") + err := os.MkdirAll(deviceDir, 0o755) + if err != nil { + t.Fatal(err) + } + + // Create device files + deviceFiles := map[string]string{ + "firmware_rev": "3D4EEXP9", + "model": "Incomplete Test SSD", + "serial": "INCOMPLETE123", + "state": "live", + "cntlid": "3072", + } + + for filename, content := range deviceFiles { + err := os.WriteFile(filepath.Join(deviceDir, filename), []byte(content), 0o644) + if err != nil { + t.Fatal(err) + } + } + + // Create namespace directory but with missing files + namespaceDir := filepath.Join(deviceDir, "nvme2c0n1") + err = os.MkdirAll(filepath.Join(namespaceDir, "queue"), 0o755) + if err != nil { + t.Fatal(err) + } + + // Only create some files, leaving ana_state missing + namespaceFiles := map[string]string{ + "nuse": "75000", + "size": "1500000000", + "queue/logical_block_size": "4096", + // ana_state is intentionally missing + } + + for filename, content := range namespaceFiles { + filePath := filepath.Join(namespaceDir, filename) + err := os.WriteFile(filePath, []byte(content), 0o644) + if err != nil { + t.Fatal(err) + } + } + + // Create filesystem and test + fs, err := NewFS(tempDir) + if err != nil { + t.Fatal(err) + } + + got, err := fs.NVMeClass() + if err != nil { + t.Fatal(err) + } + + // Verify the device was parsed correctly + if len(got) != 1 { + t.Fatalf("Expected 1 device, got %d", len(got)) + } + + device, exists := got["nvme2"] + if !exists { + t.Fatal("Expected nvme2 device not found") + } + + // Verify namespace was parsed correctly with default ana_state + if len(device.Namespaces) != 1 { + t.Fatalf("Expected 1 namespace, got %d", len(device.Namespaces)) + } + + namespace := device.Namespaces[0] + + // Should have default "unknown" ana_state + if namespace.ANAState != "unknown" { + t.Errorf("Expected ana_state 'unknown', got %s", namespace.ANAState) + } + + // Other values should be parsed correctly + if namespace.UsedBlocks != 75000 { + t.Errorf("Expected UsedBlocks 75000, got %d", namespace.UsedBlocks) + } + + if namespace.SizeBlocks != 1500000000 { + t.Errorf("Expected SizeBlocks 1500000000, got %d", namespace.SizeBlocks) + } + + if namespace.LogicalBlockSize != 4096 { + t.Errorf("Expected LogicalBlockSize 4096, got %d", namespace.LogicalBlockSize) + } +} diff --git a/testdata/fixtures.ttar b/testdata/fixtures.ttar index bff4d3c4..75cccb5b 100644 --- a/testdata/fixtures.ttar +++ b/testdata/fixtures.ttar @@ -6684,6 +6684,32 @@ Lines: 1 Samsung SSD 970 PRO 512GB Mode: 644 # ttar - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - +Directory: fixtures/sys/class/nvme/nvme0/nvme0c0n0 +Mode: 755 +# ttar - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - +Path: fixtures/sys/class/nvme/nvme0/nvme0c0n0/ana_state +Lines: 1 +optimized +Mode: 644 +# ttar - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - +Path: fixtures/sys/class/nvme/nvme0/nvme0c0n0/nuse +Lines: 1 +488281250 +Mode: 644 +# ttar - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - +Directory: fixtures/sys/class/nvme/nvme0/nvme0c0n0/queue +Mode: 755 +# ttar - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - +Path: fixtures/sys/class/nvme/nvme0/nvme0c0n0/queue/logical_block_size +Lines: 1 +4096 +Mode: 644 +# ttar - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - +Path: fixtures/sys/class/nvme/nvme0/nvme0c0n0/size +Lines: 1 +3906250000 +Mode: 644 +# ttar - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - Path: fixtures/sys/class/nvme/nvme0/serial Lines: 1 S680HF8N190894I