sysfs: add NVMeSubsystemClass for NVMe-oF subsystem parsing (#797)

Add NVMeSubsystemClass() method to sysfs.FS that reads
/sys/class/nvme-subsystem/ to discover NVMe over Fabrics subsystems.

For each subsystem it reads:
- NQN, model, serial, I/O policy
- Controller paths with state, transport, and address
- Namespace block devices (e.g. nvme0n1) for subsystem-to-device mapping

The namespace discovery enables precise correlation between NVMe block
devices and their parent subsystems, which is needed for workload-aware
storage path health alerting.

Signed-off-by: Shirly Radco <sradco@redhat.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
diff --git a/sysfs/class_nvme_subsystem.go b/sysfs/class_nvme_subsystem.go
new file mode 100644
index 0000000..c222609
--- /dev/null
+++ b/sysfs/class_nvme_subsystem.go
@@ -0,0 +1,158 @@
+// Copyright The Prometheus Authors
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+//go:build linux
+
+package sysfs
+
+import (
+	"fmt"
+	"os"
+	"regexp"
+	"strings"
+
+	"github.com/prometheus/procfs/internal/util"
+)
+
+const nvmeSubsystemClassPath = "class/nvme-subsystem"
+
+var (
+	nvmeSubsystemControllerRE = regexp.MustCompile(`^nvme\d+$`)
+	nvmeNamespaceRE           = regexp.MustCompile(`^nvme\d+n\d+$`)
+)
+
+// NVMeSubsystem contains info from /sys/class/nvme-subsystem/<subsys>/.
+type NVMeSubsystem struct {
+	// Name is the subsystem directory name, e.g. "nvme-subsys0".
+	Name string
+	// NQN is the NVMe Qualified Name from subsysnqn.
+	NQN string
+	// Model is the subsystem model string.
+	Model string
+	// Serial is the subsystem serial number.
+	Serial string
+	// IOPolicy is the multipath I/O policy, e.g. "numa", "round-robin".
+	IOPolicy string
+	// Controllers lists the NVMe controllers under this subsystem.
+	Controllers []NVMeSubsystemController
+	// Namespaces lists the NVMe namespace block devices (e.g. "nvme0n1")
+	// that belong to this subsystem.
+	Namespaces []string
+}
+
+// NVMeSubsystemController contains info about a single NVMe controller
+// within an NVMe subsystem.
+type NVMeSubsystemController struct {
+	// Name is the controller directory name, e.g. "nvme0".
+	Name string
+	// State is the controller state, e.g. "live", "connecting", "dead".
+	State string
+	// Transport is the transport type, e.g. "tcp", "fc", "rdma".
+	Transport string
+	// Address is the controller address string.
+	Address string
+}
+
+// NVMeSubsystemClass is a collection of NVMe subsystems from
+// /sys/class/nvme-subsystem.
+type NVMeSubsystemClass []NVMeSubsystem
+
+// NVMeSubsystemClass returns info for all NVMe subsystems read from
+// /sys/class/nvme-subsystem.
+func (fs FS) NVMeSubsystemClass() (NVMeSubsystemClass, error) {
+	path := fs.sys.Path(nvmeSubsystemClassPath)
+
+	entries, err := os.ReadDir(path)
+	if err != nil {
+		return nil, err
+	}
+
+	var subsystems NVMeSubsystemClass
+	for _, entry := range entries {
+		if !strings.HasPrefix(entry.Name(), "nvme-subsys") {
+			continue
+		}
+		subsys, err := fs.parseNVMeSubsystem(entry.Name())
+		if err != nil {
+			return nil, err
+		}
+		subsystems = append(subsystems, *subsys)
+	}
+
+	return subsystems, nil
+}
+
+func (fs FS) parseNVMeSubsystem(name string) (*NVMeSubsystem, error) {
+	path := fs.sys.Path(nvmeSubsystemClassPath, name)
+	subsys := &NVMeSubsystem{Name: name}
+
+	for _, attr := range [...]struct {
+		file string
+		dest *string
+	}{
+		{"subsysnqn", &subsys.NQN},
+		{"model", &subsys.Model},
+		{"serial", &subsys.Serial},
+		{"iopolicy", &subsys.IOPolicy},
+	} {
+		val, err := util.SysReadFile(fs.sys.Path(nvmeSubsystemClassPath, name, attr.file))
+		if err != nil {
+			return nil, fmt.Errorf("failed to read %s for %s: %w", attr.file, name, err)
+		}
+		*attr.dest = val
+	}
+
+	entries, err := os.ReadDir(path)
+	if err != nil {
+		return nil, fmt.Errorf("failed to list controllers for %s: %w", name, err)
+	}
+
+	seen := make(map[string]bool)
+	for _, entry := range entries {
+		eName := entry.Name()
+		if nvmeSubsystemControllerRE.MatchString(eName) {
+			ctrl, err := fs.parseNVMeSubsystemController(name, eName)
+			if err != nil {
+				return nil, err
+			}
+			subsys.Controllers = append(subsys.Controllers, *ctrl)
+		}
+		if nvmeNamespaceRE.MatchString(eName) && !seen[eName] {
+			seen[eName] = true
+			subsys.Namespaces = append(subsys.Namespaces, eName)
+		}
+	}
+
+	return subsys, nil
+}
+
+func (fs FS) parseNVMeSubsystemController(subsysName, ctrlName string) (*NVMeSubsystemController, error) {
+	ctrl := &NVMeSubsystemController{Name: ctrlName}
+
+	for _, attr := range [...]struct {
+		file string
+		dest *string
+	}{
+		{"state", &ctrl.State},
+		{"transport", &ctrl.Transport},
+		{"address", &ctrl.Address},
+	} {
+		val, err := util.SysReadFile(fs.sys.Path(nvmeSubsystemClassPath, subsysName, ctrlName, attr.file))
+		if err != nil {
+			return nil, fmt.Errorf("failed to read %s for %s/%s: %w", attr.file, subsysName, ctrlName, err)
+		}
+		*attr.dest = val
+	}
+
+	return ctrl, nil
+}
diff --git a/sysfs/class_nvme_subsystem_test.go b/sysfs/class_nvme_subsystem_test.go
new file mode 100644
index 0000000..7995712
--- /dev/null
+++ b/sysfs/class_nvme_subsystem_test.go
@@ -0,0 +1,103 @@
+// Copyright The Prometheus Authors
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+//go:build linux
+
+package sysfs
+
+import (
+	"os"
+	"path/filepath"
+	"testing"
+
+	"github.com/google/go-cmp/cmp"
+)
+
+func TestNVMeSubsystemClass(t *testing.T) {
+	fs, err := NewFS(sysTestFixtures)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	got, err := fs.NVMeSubsystemClass()
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	want := NVMeSubsystemClass{
+		{
+			Name:     "nvme-subsys0",
+			NQN:      "nqn.2014-08.org.nvmexpress:uuid:a34c4f3a-0d6f-5cec-dead-beefcafebabe",
+			Model:    "Dell PowerStore",
+			Serial:   "SN12345678",
+			IOPolicy: "round-robin",
+			Controllers: []NVMeSubsystemController{
+				{Name: "nvme0", State: "live", Transport: "fc", Address: "nn-0x200400a0986b4321:pn-0x210400a0986b4321"},
+				{Name: "nvme1", State: "live", Transport: "fc", Address: "nn-0x200400a0986b4322:pn-0x210400a0986b4322"},
+				{Name: "nvme2", State: "dead", Transport: "fc", Address: "nn-0x200400a0986b4323:pn-0x210400a0986b4323"},
+			},
+			Namespaces: []string{"nvme0n1"},
+		},
+		{
+			Name:     "nvme-subsys1",
+			NQN:      "nqn.2014-08.org.nvmexpress:uuid:b45d5e4b-1e7f-6ded-beef-deadcafe1234",
+			Model:    "NetApp ONTAP",
+			Serial:   "NTAP98765",
+			IOPolicy: "numa",
+			Controllers: []NVMeSubsystemController{
+				{Name: "nvme3", State: "live", Transport: "tcp", Address: "traddr=10.0.0.1,trsvcid=4420"},
+				{Name: "nvme4", State: "connecting", Transport: "rdma", Address: "traddr=10.0.0.2,trsvcid=4420"},
+			},
+			Namespaces: []string{"nvme3n1", "nvme4n1"},
+		},
+	}
+
+	if diff := cmp.Diff(want, got); diff != "" {
+		t.Fatalf("unexpected NVMeSubsystemClass (-want +got):\n%s", diff)
+	}
+}
+
+func TestNVMeSubsystemClassNotPresent(t *testing.T) {
+	root := t.TempDir()
+
+	fs, err := NewFS(root)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	_, err = fs.NVMeSubsystemClass()
+	if err == nil {
+		t.Fatal("expected error when nvme-subsystem directory does not exist")
+	}
+}
+
+func TestNVMeSubsystemClassEmpty(t *testing.T) {
+	root := t.TempDir()
+	if err := os.MkdirAll(filepath.Join(root, "class", "nvme-subsystem"), 0o755); err != nil {
+		t.Fatal(err)
+	}
+
+	fs, err := NewFS(root)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	got, err := fs.NVMeSubsystemClass()
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	if len(got) != 0 {
+		t.Fatalf("expected 0 subsystems, got %d", len(got))
+	}
+}
diff --git a/testdata/fixtures.ttar b/testdata/fixtures.ttar
index b7b77ff..55821e3 100644
--- a/testdata/fixtures.ttar
+++ b/testdata/fixtures.ttar
Binary files differ