mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-09-01 13:47:51 +00:00
NVMe/TCP transport support in the CSI driver so Kubernetes pods can mount block volumes via NVMe alongside (or instead of) iSCSI. Transport selection: NVMe preferred when nvme_tcp module loaded + metadata present + nvmeUtil available. Fail-fast on NVMe errors (no silent iSCSI fallback). .transport file persists across CSI restarts. Key changes: - BuildNQN() single source of truth for NQN construction (naming.go) - NVMeUtil interface + realNVMeUtil wrapping nvme-cli (nvme_util.go) - NodeStageVolume/Unstage/Expand dual-transport paths (node.go) - NvmeAddr/NQN fields in VolumeInfo, Controller contexts - VolumeManager NvmeAddr()/VolumeNQN() getters - BlockService NvmeListenAddr()/NQN() accessors - 27 unit tests + 26 QA adversarial tests (nvme_node_test.go, qa_cp102) - Fix: flaky TestQA_Node_ConcurrentStageUnstage (pre-alloc temp dirs) Review fixes applied: F1 (NQN format mismatch), F2 (CreateVolume drops NVMe context), F3 (IsConnected error classification), F4 (findSubsys path validation), F5 (MasterVolumeClient NVMe gap documented). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
531 lines
16 KiB
Go
531 lines
16 KiB
Go
package csi
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"log"
|
|
"os"
|
|
"path/filepath"
|
|
"sync"
|
|
|
|
"github.com/container-storage-interface/spec/lib/go/csi"
|
|
"github.com/seaweedfs/seaweedfs/weed/storage/blockvol"
|
|
"google.golang.org/grpc/codes"
|
|
"google.golang.org/grpc/status"
|
|
)
|
|
|
|
const (
|
|
transportISCSI = "iscsi"
|
|
transportNVMe = "nvme"
|
|
)
|
|
|
|
// stagedVolumeInfo tracks info needed for NodeUnstageVolume and NodeExpandVolume.
|
|
type stagedVolumeInfo struct {
|
|
iqn string
|
|
iscsiAddr string
|
|
nqn string // NVMe subsystem NQN
|
|
nvmeAddr string // NVMe/TCP target address
|
|
transport string // "iscsi" or "nvme"
|
|
isLocal bool // true if volume is served by local VolumeManager
|
|
fsType string // filesystem type (ext4, xfs, etc.)
|
|
stagingPath string // staging mount path
|
|
}
|
|
|
|
type nodeServer struct {
|
|
csi.UnimplementedNodeServer
|
|
mgr *VolumeManager // may be nil in controller-only mode
|
|
nodeID string
|
|
iqnPrefix string // for IQN derivation fallback on restart
|
|
nqnPrefix string // for NQN derivation fallback on restart
|
|
iscsiUtil ISCSIUtil
|
|
nvmeUtil NVMeUtil // may be nil if NVMe not available
|
|
mountUtil MountUtil
|
|
logger *log.Logger
|
|
|
|
stagedMu sync.Mutex
|
|
staged map[string]*stagedVolumeInfo // volumeID -> staged info
|
|
}
|
|
|
|
// transportFile is the filename written inside the staging directory to persist
|
|
// the transport type across CSI plugin restarts.
|
|
const transportFile = ".transport"
|
|
|
|
func (s *nodeServer) NodeStageVolume(ctx context.Context, req *csi.NodeStageVolumeRequest) (*csi.NodeStageVolumeResponse, error) {
|
|
volumeID := req.VolumeId
|
|
stagingPath := req.StagingTargetPath
|
|
|
|
if volumeID == "" {
|
|
return nil, status.Error(codes.InvalidArgument, "volume ID is required")
|
|
}
|
|
if stagingPath == "" {
|
|
return nil, status.Error(codes.InvalidArgument, "staging target path is required")
|
|
}
|
|
if req.VolumeCapability == nil {
|
|
return nil, status.Error(codes.InvalidArgument, "volume capability is required")
|
|
}
|
|
|
|
// Idempotency: if already mounted at staging path, return OK.
|
|
mounted, err := s.mountUtil.IsMounted(ctx, stagingPath)
|
|
if err != nil {
|
|
return nil, status.Errorf(codes.Internal, "check mount: %v", err)
|
|
}
|
|
if mounted {
|
|
s.logger.Printf("NodeStageVolume: %s already mounted at %s", volumeID, stagingPath)
|
|
return &csi.NodeStageVolumeResponse{}, nil
|
|
}
|
|
|
|
// Resolve iSCSI target info.
|
|
// Priority: publish_context > volume_context > local volume manager fallback.
|
|
var iqn, portal string
|
|
isLocal := false
|
|
|
|
if req.PublishContext != nil && req.PublishContext["iscsiAddr"] != "" && req.PublishContext["iqn"] != "" {
|
|
portal = req.PublishContext["iscsiAddr"]
|
|
iqn = req.PublishContext["iqn"]
|
|
} else if req.VolumeContext != nil && req.VolumeContext["iscsiAddr"] != "" && req.VolumeContext["iqn"] != "" {
|
|
portal = req.VolumeContext["iscsiAddr"]
|
|
iqn = req.VolumeContext["iqn"]
|
|
} else if s.mgr != nil {
|
|
isLocal = true
|
|
if err := s.mgr.OpenVolume(volumeID); err != nil {
|
|
return nil, status.Errorf(codes.Internal, "open volume: %v", err)
|
|
}
|
|
iqn = s.mgr.VolumeIQN(volumeID)
|
|
portal = s.mgr.ListenAddr()
|
|
}
|
|
|
|
// Resolve NVMe target info (same priority chain).
|
|
// PublishContext > VolumeContext > local VolumeManager.
|
|
var nqn, nvmeAddr string
|
|
if req.PublishContext != nil && req.PublishContext["nvmeAddr"] != "" && req.PublishContext["nqn"] != "" {
|
|
nvmeAddr = req.PublishContext["nvmeAddr"]
|
|
nqn = req.PublishContext["nqn"]
|
|
} else if req.VolumeContext != nil && req.VolumeContext["nvmeAddr"] != "" && req.VolumeContext["nqn"] != "" {
|
|
nvmeAddr = req.VolumeContext["nvmeAddr"]
|
|
nqn = req.VolumeContext["nqn"]
|
|
} else if s.mgr != nil && s.mgr.NvmeAddr() != "" {
|
|
nvmeAddr = s.mgr.NvmeAddr()
|
|
nqn = s.mgr.VolumeNQN(volumeID)
|
|
}
|
|
|
|
// No transport info at all (neither iSCSI nor NVMe resolved, no local mgr).
|
|
if iqn == "" && nqn == "" {
|
|
return nil, status.Error(codes.FailedPrecondition, "no volume_context and no local volume manager")
|
|
}
|
|
|
|
// Cleanup on error.
|
|
success := false
|
|
defer func() {
|
|
if !success {
|
|
s.logger.Printf("NodeStageVolume: cleaning up %s after error", volumeID)
|
|
if isLocal && s.mgr != nil {
|
|
s.mgr.CloseVolume(volumeID)
|
|
}
|
|
}
|
|
}()
|
|
|
|
// Transport selection: prefer NVMe if supported, fall back to iSCSI.
|
|
var device, transport string
|
|
|
|
nvmeAvailable := nvmeAddr != "" && nqn != "" && s.nvmeUtil != nil && s.nvmeUtil.IsNVMeTCPAvailable()
|
|
if nvmeAvailable {
|
|
// NVMe path — fail fast on error, no fallback to iSCSI.
|
|
transport = transportNVMe
|
|
|
|
connected, cerr := s.nvmeUtil.IsConnected(ctx, nqn)
|
|
if cerr != nil {
|
|
return nil, status.Errorf(codes.Internal, "check nvme connection: %v", cerr)
|
|
}
|
|
if !connected {
|
|
if cerr := s.nvmeUtil.Connect(ctx, nqn, nvmeAddr); cerr != nil {
|
|
return nil, status.Errorf(codes.Internal, "nvme connect: %v", cerr)
|
|
}
|
|
}
|
|
|
|
// Cleanup NVMe on subsequent failures.
|
|
defer func() {
|
|
if !success {
|
|
s.nvmeUtil.Disconnect(ctx, nqn)
|
|
}
|
|
}()
|
|
|
|
device, err = s.nvmeUtil.GetDeviceByNQN(ctx, nqn)
|
|
if err != nil {
|
|
return nil, status.Errorf(codes.Internal, "nvme get device: %v", err)
|
|
}
|
|
} else if iqn != "" && portal != "" {
|
|
// iSCSI path (existing code).
|
|
transport = transportISCSI
|
|
|
|
loggedIn, lerr := s.iscsiUtil.IsLoggedIn(ctx, iqn)
|
|
if lerr != nil {
|
|
return nil, status.Errorf(codes.Internal, "check iscsi login: %v", lerr)
|
|
}
|
|
if !loggedIn {
|
|
if err := s.iscsiUtil.Discovery(ctx, portal); err != nil {
|
|
return nil, status.Errorf(codes.Internal, "iscsi discovery: %v", err)
|
|
}
|
|
if err := s.iscsiUtil.Login(ctx, iqn, portal); err != nil {
|
|
return nil, status.Errorf(codes.Internal, "iscsi login: %v", err)
|
|
}
|
|
}
|
|
|
|
device, err = s.iscsiUtil.GetDeviceByIQN(ctx, iqn)
|
|
if err != nil {
|
|
return nil, status.Errorf(codes.Internal, "get device: %v", err)
|
|
}
|
|
} else {
|
|
return nil, status.Error(codes.FailedPrecondition, "no transport available")
|
|
}
|
|
|
|
// Ensure staging directory exists.
|
|
if err := os.MkdirAll(stagingPath, 0750); err != nil {
|
|
return nil, status.Errorf(codes.Internal, "create staging dir: %v", err)
|
|
}
|
|
|
|
// Format (if needed) and mount.
|
|
fsType := "ext4"
|
|
if req.VolumeCapability != nil {
|
|
if mnt := req.VolumeCapability.GetMount(); mnt != nil && mnt.FsType != "" {
|
|
fsType = mnt.FsType
|
|
}
|
|
}
|
|
|
|
if err := s.mountUtil.FormatAndMount(ctx, device, stagingPath, fsType); err != nil {
|
|
return nil, status.Errorf(codes.Internal, "format and mount: %v", err)
|
|
}
|
|
|
|
// Write transport marker for restart recovery.
|
|
if werr := writeTransportFile(stagingPath, transport); werr != nil {
|
|
s.logger.Printf("NodeStageVolume: %s: %v (non-fatal)", volumeID, werr)
|
|
}
|
|
|
|
// Track staged volume for unstage.
|
|
s.stagedMu.Lock()
|
|
if s.staged == nil {
|
|
s.staged = make(map[string]*stagedVolumeInfo)
|
|
}
|
|
s.staged[volumeID] = &stagedVolumeInfo{
|
|
iqn: iqn,
|
|
iscsiAddr: portal,
|
|
nqn: nqn,
|
|
nvmeAddr: nvmeAddr,
|
|
transport: transport,
|
|
isLocal: isLocal,
|
|
fsType: fsType,
|
|
stagingPath: stagingPath,
|
|
}
|
|
s.stagedMu.Unlock()
|
|
|
|
success = true
|
|
s.logger.Printf("NodeStageVolume: %s staged at %s (device=%s, transport=%s, local=%v)", volumeID, stagingPath, device, transport, isLocal)
|
|
return &csi.NodeStageVolumeResponse{}, nil
|
|
}
|
|
|
|
func (s *nodeServer) NodeUnstageVolume(ctx context.Context, req *csi.NodeUnstageVolumeRequest) (*csi.NodeUnstageVolumeResponse, error) {
|
|
volumeID := req.VolumeId
|
|
stagingPath := req.StagingTargetPath
|
|
|
|
if volumeID == "" {
|
|
return nil, status.Error(codes.InvalidArgument, "volume ID is required")
|
|
}
|
|
if stagingPath == "" {
|
|
return nil, status.Error(codes.InvalidArgument, "staging target path is required")
|
|
}
|
|
|
|
// Look up staged info.
|
|
s.stagedMu.Lock()
|
|
info := s.staged[volumeID]
|
|
s.stagedMu.Unlock()
|
|
|
|
// Determine transport and identifiers.
|
|
var iqn, nqn, transport string
|
|
isLocal := false
|
|
|
|
if info != nil {
|
|
iqn = info.iqn
|
|
nqn = info.nqn
|
|
transport = info.transport
|
|
isLocal = info.isLocal
|
|
} else {
|
|
// Restart fallback: read .transport file from staging path.
|
|
transport = readTransportFile(stagingPath)
|
|
|
|
// Derive identifiers.
|
|
if s.mgr != nil {
|
|
iqn = s.mgr.VolumeIQN(volumeID)
|
|
nqn = s.mgr.VolumeNQN(volumeID)
|
|
isLocal = true
|
|
} else {
|
|
if s.iqnPrefix != "" {
|
|
iqn = s.iqnPrefix + ":" + blockvol.SanitizeIQN(volumeID)
|
|
}
|
|
if s.nqnPrefix != "" {
|
|
nqn = blockvol.BuildNQN(s.nqnPrefix, volumeID)
|
|
}
|
|
}
|
|
|
|
// If no .transport file, probe NVMe connection to determine transport.
|
|
if transport == "" && nqn != "" && s.nvmeUtil != nil {
|
|
if connected, _ := s.nvmeUtil.IsConnected(ctx, nqn); connected {
|
|
transport = transportNVMe
|
|
}
|
|
}
|
|
if transport == "" {
|
|
transport = transportISCSI // default fallback
|
|
}
|
|
|
|
s.logger.Printf("NodeUnstageVolume: %s not in staged map, derived transport=%s", volumeID, transport)
|
|
}
|
|
|
|
// Best-effort cleanup: always attempt all steps even if one fails.
|
|
var firstErr error
|
|
|
|
// Unmount.
|
|
if err := s.mountUtil.Unmount(ctx, stagingPath); err != nil {
|
|
s.logger.Printf("NodeUnstageVolume: unmount error: %v", err)
|
|
firstErr = err
|
|
}
|
|
|
|
// Disconnect transport.
|
|
switch transport {
|
|
case transportNVMe:
|
|
if nqn != "" && s.nvmeUtil != nil {
|
|
if err := s.nvmeUtil.Disconnect(ctx, nqn); err != nil {
|
|
s.logger.Printf("NodeUnstageVolume: nvme disconnect error: %v", err)
|
|
if firstErr == nil {
|
|
firstErr = err
|
|
}
|
|
}
|
|
}
|
|
default: // iSCSI
|
|
if iqn != "" {
|
|
if err := s.iscsiUtil.Logout(ctx, iqn); err != nil {
|
|
s.logger.Printf("NodeUnstageVolume: logout error: %v", err)
|
|
if firstErr == nil {
|
|
firstErr = err
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Close the local volume if applicable.
|
|
if isLocal && s.mgr != nil {
|
|
if err := s.mgr.CloseVolume(volumeID); err != nil {
|
|
s.logger.Printf("NodeUnstageVolume: close volume error: %v", err)
|
|
if firstErr == nil {
|
|
firstErr = err
|
|
}
|
|
}
|
|
}
|
|
|
|
if firstErr != nil {
|
|
// Keep staged entry so retry has correct info.
|
|
return nil, status.Errorf(codes.Internal, "unstage: %v", firstErr)
|
|
}
|
|
|
|
// Clean up transport file.
|
|
os.Remove(filepath.Join(stagingPath, transportFile))
|
|
|
|
// Remove from staged map only after successful cleanup.
|
|
s.stagedMu.Lock()
|
|
delete(s.staged, volumeID)
|
|
s.stagedMu.Unlock()
|
|
|
|
s.logger.Printf("NodeUnstageVolume: %s unstaged from %s", volumeID, stagingPath)
|
|
return &csi.NodeUnstageVolumeResponse{}, nil
|
|
}
|
|
|
|
func (s *nodeServer) NodePublishVolume(ctx context.Context, req *csi.NodePublishVolumeRequest) (*csi.NodePublishVolumeResponse, error) {
|
|
volumeID := req.VolumeId
|
|
targetPath := req.TargetPath
|
|
stagingPath := req.StagingTargetPath
|
|
|
|
if volumeID == "" {
|
|
return nil, status.Error(codes.InvalidArgument, "volume ID is required")
|
|
}
|
|
if targetPath == "" {
|
|
return nil, status.Error(codes.InvalidArgument, "target path is required")
|
|
}
|
|
if stagingPath == "" {
|
|
return nil, status.Error(codes.InvalidArgument, "staging target path is required")
|
|
}
|
|
|
|
// Idempotency: if already bind-mounted, return OK.
|
|
mounted, err := s.mountUtil.IsMounted(ctx, targetPath)
|
|
if err != nil {
|
|
return nil, status.Errorf(codes.Internal, "check mount: %v", err)
|
|
}
|
|
if mounted {
|
|
s.logger.Printf("NodePublishVolume: %s already mounted at %s", volumeID, targetPath)
|
|
return &csi.NodePublishVolumeResponse{}, nil
|
|
}
|
|
|
|
// Ensure target directory exists.
|
|
if err := os.MkdirAll(targetPath, 0750); err != nil {
|
|
return nil, status.Errorf(codes.Internal, "create target dir: %v", err)
|
|
}
|
|
|
|
// Bind mount staging path to target path.
|
|
readOnly := req.Readonly
|
|
if err := s.mountUtil.BindMount(ctx, stagingPath, targetPath, readOnly); err != nil {
|
|
return nil, status.Errorf(codes.Internal, "bind mount: %v", err)
|
|
}
|
|
|
|
s.logger.Printf("NodePublishVolume: %s published at %s", volumeID, targetPath)
|
|
return &csi.NodePublishVolumeResponse{}, nil
|
|
}
|
|
|
|
func (s *nodeServer) NodeUnpublishVolume(ctx context.Context, req *csi.NodeUnpublishVolumeRequest) (*csi.NodeUnpublishVolumeResponse, error) {
|
|
if req.VolumeId == "" {
|
|
return nil, status.Error(codes.InvalidArgument, "volume ID is required")
|
|
}
|
|
if req.TargetPath == "" {
|
|
return nil, status.Error(codes.InvalidArgument, "target path is required")
|
|
}
|
|
|
|
// Idempotent: only unmount if still mounted.
|
|
mounted, err := s.mountUtil.IsMounted(ctx, req.TargetPath)
|
|
if err != nil {
|
|
return nil, status.Errorf(codes.Internal, "check mount: %v", err)
|
|
}
|
|
if mounted {
|
|
if err := s.mountUtil.Unmount(ctx, req.TargetPath); err != nil {
|
|
return nil, status.Errorf(codes.Internal, "unmount: %v", err)
|
|
}
|
|
}
|
|
|
|
// CSI spec: remove mount point at target path.
|
|
os.RemoveAll(req.TargetPath)
|
|
|
|
s.logger.Printf("NodeUnpublishVolume: %s unpublished from %s", req.VolumeId, req.TargetPath)
|
|
return &csi.NodeUnpublishVolumeResponse{}, nil
|
|
}
|
|
|
|
func (s *nodeServer) NodeExpandVolume(ctx context.Context, req *csi.NodeExpandVolumeRequest) (*csi.NodeExpandVolumeResponse, error) {
|
|
if req.VolumeId == "" {
|
|
return nil, status.Error(codes.InvalidArgument, "volume ID is required")
|
|
}
|
|
|
|
// Look up staged volume info.
|
|
s.stagedMu.Lock()
|
|
info, ok := s.staged[req.VolumeId]
|
|
s.stagedMu.Unlock()
|
|
if !ok {
|
|
return nil, status.Errorf(codes.FailedPrecondition, "volume %q not staged", req.VolumeId)
|
|
}
|
|
|
|
var device string
|
|
var err error
|
|
|
|
switch info.transport {
|
|
case transportNVMe:
|
|
// NVMe: rescan namespace, then find device.
|
|
if s.nvmeUtil == nil {
|
|
return nil, status.Errorf(codes.Internal, "nvme util not available")
|
|
}
|
|
if err := s.nvmeUtil.Rescan(ctx, info.nqn); err != nil {
|
|
return nil, status.Errorf(codes.Internal, "nvme rescan: %v", err)
|
|
}
|
|
device, err = s.nvmeUtil.GetDeviceByNQN(ctx, info.nqn)
|
|
if err != nil {
|
|
return nil, status.Errorf(codes.Internal, "nvme find device: %v", err)
|
|
}
|
|
default: // iSCSI
|
|
// Check that iSCSI session is active.
|
|
loggedIn, lerr := s.iscsiUtil.IsLoggedIn(ctx, info.iqn)
|
|
if lerr != nil {
|
|
return nil, status.Errorf(codes.Internal, "check iSCSI session: %v", lerr)
|
|
}
|
|
if !loggedIn {
|
|
return nil, status.Errorf(codes.FailedPrecondition, "volume %q not staged: iSCSI session not active", req.VolumeId)
|
|
}
|
|
if err := s.iscsiUtil.RescanDevice(ctx, info.iqn); err != nil {
|
|
return nil, status.Errorf(codes.Internal, "rescan iSCSI device: %v", err)
|
|
}
|
|
device, err = s.iscsiUtil.GetDeviceByIQN(ctx, info.iqn)
|
|
if err != nil {
|
|
return nil, status.Errorf(codes.Internal, "find device: %v", err)
|
|
}
|
|
}
|
|
|
|
// Determine mount path and fsType.
|
|
mountPath := info.stagingPath
|
|
if req.VolumePath != "" {
|
|
mountPath = req.VolumePath
|
|
}
|
|
fsType := info.fsType
|
|
if fsType == "" {
|
|
fsType = "ext4"
|
|
}
|
|
|
|
// Resize the filesystem.
|
|
if err := s.mountUtil.ResizeFS(ctx, device, mountPath, fsType); err != nil {
|
|
return nil, status.Errorf(codes.Internal, "resize filesystem: %v", err)
|
|
}
|
|
|
|
s.logger.Printf("NodeExpandVolume: %s expanded (device=%s, transport=%s, fs=%s)", req.VolumeId, device, info.transport, fsType)
|
|
|
|
capacity := int64(0)
|
|
if req.CapacityRange != nil {
|
|
capacity = req.CapacityRange.RequiredBytes
|
|
}
|
|
return &csi.NodeExpandVolumeResponse{
|
|
CapacityBytes: capacity,
|
|
}, nil
|
|
}
|
|
|
|
func (s *nodeServer) NodeGetCapabilities(_ context.Context, _ *csi.NodeGetCapabilitiesRequest) (*csi.NodeGetCapabilitiesResponse, error) {
|
|
caps := []csi.NodeServiceCapability_RPC_Type{
|
|
csi.NodeServiceCapability_RPC_STAGE_UNSTAGE_VOLUME,
|
|
csi.NodeServiceCapability_RPC_EXPAND_VOLUME,
|
|
}
|
|
|
|
var result []*csi.NodeServiceCapability
|
|
for _, c := range caps {
|
|
result = append(result, &csi.NodeServiceCapability{
|
|
Type: &csi.NodeServiceCapability_Rpc{
|
|
Rpc: &csi.NodeServiceCapability_RPC{Type: c},
|
|
},
|
|
})
|
|
}
|
|
return &csi.NodeGetCapabilitiesResponse{Capabilities: result}, nil
|
|
}
|
|
|
|
func (s *nodeServer) NodeGetInfo(_ context.Context, _ *csi.NodeGetInfoRequest) (*csi.NodeGetInfoResponse, error) {
|
|
return &csi.NodeGetInfoResponse{
|
|
NodeId: s.nodeID,
|
|
MaxVolumesPerNode: 256,
|
|
AccessibleTopology: &csi.Topology{
|
|
Segments: map[string]string{
|
|
fmt.Sprintf("topology.%s/node", DriverName): s.nodeID,
|
|
},
|
|
},
|
|
}, nil
|
|
}
|
|
|
|
// writeTransportFile writes the transport type to a file inside the staging path
|
|
// so it can be recovered after CSI plugin restart.
|
|
func writeTransportFile(stagingPath, transport string) error {
|
|
path := filepath.Join(stagingPath, transportFile)
|
|
if err := os.WriteFile(path, []byte(transport), 0600); err != nil {
|
|
return fmt.Errorf("write transport file: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// readTransportFile reads the transport type from the staging path.
|
|
// Returns empty string if the file doesn't exist.
|
|
func readTransportFile(stagingPath string) string {
|
|
path := filepath.Join(stagingPath, transportFile)
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
t := string(data)
|
|
if t == transportISCSI || t == transportNVMe {
|
|
return t
|
|
}
|
|
return ""
|
|
}
|