mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-10-17 03:28:32 +02: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>
1005 lines
30 KiB
Go
1005 lines
30 KiB
Go
package csi
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"log"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
"testing"
|
|
|
|
"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"
|
|
)
|
|
|
|
// ============================================================
|
|
// QA-NODE-CP62: Node adversarial tests for remote + staged map
|
|
// ============================================================
|
|
|
|
// QA-NODE-R1: Stage a remote target then unstage — staged map must track isLocal=false.
|
|
// Unstage should NOT call CloseVolume (remote volumes aren't managed locally).
|
|
func TestQA_Node_RemoteUnstageNoCloseVolume(t *testing.T) {
|
|
ns, mi, mm := newTestNodeServer(t)
|
|
mi.getDeviceResult = "/dev/sdb"
|
|
|
|
stagingPath := t.TempDir()
|
|
|
|
// Stage with remote volume_context (not using local mgr).
|
|
_, err := ns.NodeStageVolume(context.Background(), &csi.NodeStageVolumeRequest{
|
|
VolumeId: "remote-vol",
|
|
StagingTargetPath: stagingPath,
|
|
VolumeCapability: testVolCap(),
|
|
VolumeContext: map[string]string{
|
|
"iscsiAddr": "10.0.0.5:3260",
|
|
"iqn": "iqn.2024.com.seaweedfs:remote-vol",
|
|
},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("stage remote: %v", err)
|
|
}
|
|
|
|
// Verify staged entry has isLocal=false.
|
|
ns.stagedMu.Lock()
|
|
info := ns.staged["remote-vol"]
|
|
ns.stagedMu.Unlock()
|
|
if info == nil {
|
|
t.Fatal("remote-vol not in staged map")
|
|
}
|
|
if info.isLocal {
|
|
t.Fatal("BUG: remote volume should have isLocal=false")
|
|
}
|
|
|
|
// Unstage.
|
|
_, err = ns.NodeUnstageVolume(context.Background(), &csi.NodeUnstageVolumeRequest{
|
|
VolumeId: "remote-vol",
|
|
StagingTargetPath: stagingPath,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("unstage: %v", err)
|
|
}
|
|
|
|
// Verify CloseVolume was NOT called (remote volume).
|
|
for _, call := range mm.calls {
|
|
if strings.Contains(call, "close") {
|
|
t.Error("BUG: CloseVolume should not be called for remote volumes")
|
|
}
|
|
}
|
|
|
|
// Verify staged entry is removed after successful unstage.
|
|
ns.stagedMu.Lock()
|
|
if _, ok := ns.staged["remote-vol"]; ok {
|
|
t.Error("BUG: staged entry should be removed after successful unstage")
|
|
}
|
|
ns.stagedMu.Unlock()
|
|
}
|
|
|
|
// QA-NODE-R2: Stage remote, then unstage fails (unmount error) — staged entry preserved.
|
|
func TestQA_Node_RemoteUnstageFailPreservesStaged(t *testing.T) {
|
|
ns, mi, mm := newTestNodeServer(t)
|
|
mi.getDeviceResult = "/dev/sdb"
|
|
|
|
stagingPath := t.TempDir()
|
|
|
|
// Stage remote.
|
|
_, err := ns.NodeStageVolume(context.Background(), &csi.NodeStageVolumeRequest{
|
|
VolumeId: "fail-vol",
|
|
StagingTargetPath: stagingPath,
|
|
VolumeCapability: testVolCap(),
|
|
VolumeContext: map[string]string{
|
|
"iscsiAddr": "10.0.0.5:3260",
|
|
"iqn": "iqn.2024.com.seaweedfs:fail-vol",
|
|
},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("stage: %v", err)
|
|
}
|
|
|
|
// Make unmount fail.
|
|
mm.unmountErr = errors.New("device busy")
|
|
|
|
_, err = ns.NodeUnstageVolume(context.Background(), &csi.NodeUnstageVolumeRequest{
|
|
VolumeId: "fail-vol",
|
|
StagingTargetPath: stagingPath,
|
|
})
|
|
if err == nil {
|
|
t.Fatal("expected error from unmount failure")
|
|
}
|
|
|
|
// Staged entry should still be present (for retry).
|
|
ns.stagedMu.Lock()
|
|
info := ns.staged["fail-vol"]
|
|
ns.stagedMu.Unlock()
|
|
if info == nil {
|
|
t.Fatal("BUG: staged entry removed despite unstage failure")
|
|
}
|
|
if info.iqn != "iqn.2024.com.seaweedfs:fail-vol" {
|
|
t.Fatalf("staged IQN mismatch: %q", info.iqn)
|
|
}
|
|
}
|
|
|
|
// QA-NODE-R3: Concurrent Stage and Unstage for the same volumeID.
|
|
func TestQA_Node_ConcurrentStageUnstage(t *testing.T) {
|
|
ns, mi, mm := newTestNodeServer(t)
|
|
mi.getDeviceResult = "/dev/sdb"
|
|
_ = mm
|
|
|
|
var wg sync.WaitGroup
|
|
var panicked atomic.Bool
|
|
|
|
// Pre-allocate temp dirs to avoid calling t.TempDir() inside goroutines
|
|
// (t.TempDir() panics if called after test cleanup).
|
|
stagingDirs := make([]string, 20)
|
|
for i := range stagingDirs {
|
|
stagingDirs[i] = t.TempDir()
|
|
}
|
|
|
|
for i := 0; i < 20; i++ {
|
|
wg.Add(2)
|
|
go func(dir string) {
|
|
defer wg.Done()
|
|
defer func() {
|
|
if r := recover(); r != nil {
|
|
panicked.Store(true)
|
|
t.Errorf("PANIC in stage: %v", r)
|
|
}
|
|
}()
|
|
ns.NodeStageVolume(context.Background(), &csi.NodeStageVolumeRequest{
|
|
VolumeId: "test-vol",
|
|
StagingTargetPath: dir,
|
|
VolumeCapability: testVolCap(),
|
|
})
|
|
}(stagingDirs[i])
|
|
go func() {
|
|
defer wg.Done()
|
|
defer func() {
|
|
if r := recover(); r != nil {
|
|
panicked.Store(true)
|
|
t.Errorf("PANIC in unstage: %v", r)
|
|
}
|
|
}()
|
|
ns.NodeUnstageVolume(context.Background(), &csi.NodeUnstageVolumeRequest{
|
|
VolumeId: "test-vol",
|
|
StagingTargetPath: "/tmp/staging",
|
|
})
|
|
}()
|
|
}
|
|
wg.Wait()
|
|
|
|
if panicked.Load() {
|
|
t.Fatal("BUG: concurrent stage/unstage caused panic")
|
|
}
|
|
}
|
|
|
|
// QA-NODE-R4: Stage with remote volume_context should use the remote portal, not local.
|
|
// Verify iSCSI discovery and login calls use the correct portal from volume_context.
|
|
func TestQA_Node_RemotePortalUsedCorrectly(t *testing.T) {
|
|
ns, mi, _ := newTestNodeServer(t)
|
|
mi.getDeviceResult = "/dev/sdc"
|
|
|
|
stagingPath := t.TempDir()
|
|
_, err := ns.NodeStageVolume(context.Background(), &csi.NodeStageVolumeRequest{
|
|
VolumeId: "remote-portal-vol",
|
|
StagingTargetPath: stagingPath,
|
|
VolumeCapability: testVolCap(),
|
|
VolumeContext: map[string]string{
|
|
"iscsiAddr": "192.168.1.100:3260",
|
|
"iqn": "iqn.2024.com.seaweedfs:remote-portal-vol",
|
|
},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("stage: %v", err)
|
|
}
|
|
|
|
// Verify discovery was called with the REMOTE portal, not local.
|
|
foundDiscovery := false
|
|
for _, call := range mi.calls {
|
|
if strings.Contains(call, "discovery:192.168.1.100:3260") {
|
|
foundDiscovery = true
|
|
}
|
|
if strings.Contains(call, "discovery:127.0.0.1") {
|
|
t.Error("BUG: discovery used local portal instead of remote")
|
|
}
|
|
}
|
|
if !foundDiscovery {
|
|
t.Error("discovery not called with remote portal 192.168.1.100:3260")
|
|
}
|
|
}
|
|
|
|
// QA-NODE-R5: Stage with partial volume_context (only iscsiAddr, no iqn) should fallback to local.
|
|
func TestQA_Node_PartialVolumeContext(t *testing.T) {
|
|
ns, mi, _ := newTestNodeServer(t)
|
|
mi.getDeviceResult = "/dev/sda"
|
|
|
|
stagingPath := t.TempDir()
|
|
_, err := ns.NodeStageVolume(context.Background(), &csi.NodeStageVolumeRequest{
|
|
VolumeId: "test-vol",
|
|
StagingTargetPath: stagingPath,
|
|
VolumeCapability: testVolCap(),
|
|
VolumeContext: map[string]string{
|
|
"iscsiAddr": "10.0.0.5:3260",
|
|
// Missing "iqn" — should fallback to local.
|
|
},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("stage: %v", err)
|
|
}
|
|
|
|
// Should have used local mgr (isLocal=true).
|
|
ns.stagedMu.Lock()
|
|
info := ns.staged["test-vol"]
|
|
ns.stagedMu.Unlock()
|
|
if info == nil {
|
|
t.Fatal("not in staged map")
|
|
}
|
|
if !info.isLocal {
|
|
t.Error("BUG: partial volume_context should fallback to local")
|
|
}
|
|
}
|
|
|
|
// QA-NODE-R6: Unstage after restart with no mgr and no iqnPrefix — IQN derivation fails gracefully.
|
|
func TestQA_Node_UnstageNoMgrNoPrefix(t *testing.T) {
|
|
mi := newMockISCSIUtil()
|
|
mm := newMockMountUtil()
|
|
ns := &nodeServer{
|
|
mgr: nil,
|
|
nodeID: "test-node",
|
|
iqnPrefix: "", // no prefix
|
|
iscsiUtil: mi,
|
|
mountUtil: mm,
|
|
logger: log.New(os.Stderr, "[test-qa] ", log.LstdFlags),
|
|
staged: make(map[string]*stagedVolumeInfo),
|
|
}
|
|
|
|
// Unstage with no staged info, no mgr, no prefix — should still succeed
|
|
// (logout is skipped because IQN is empty).
|
|
_, err := ns.NodeUnstageVolume(context.Background(), &csi.NodeUnstageVolumeRequest{
|
|
VolumeId: "ghost-vol",
|
|
StagingTargetPath: t.TempDir(),
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("unstage should succeed gracefully: %v", err)
|
|
}
|
|
|
|
// Verify logout was NOT called (no IQN to logout from).
|
|
for _, call := range mi.calls {
|
|
if strings.Contains(call, "logout") {
|
|
t.Error("BUG: logout called without IQN")
|
|
}
|
|
}
|
|
}
|
|
|
|
// ============================================================
|
|
// QA-CTRL-CP62: Controller adversarial tests with VolumeBackend
|
|
// ============================================================
|
|
|
|
// QA-CTRL-B1: CreateVolume returns volume_context with iSCSI info.
|
|
func TestQA_Ctrl_VolumeContextPresent(t *testing.T) {
|
|
mgr := newTestManager(t)
|
|
cs := &controllerServer{backend: NewLocalVolumeBackend(mgr)}
|
|
|
|
resp, err := cs.CreateVolume(context.Background(), &csi.CreateVolumeRequest{
|
|
Name: "ctx-vol",
|
|
VolumeCapabilities: testVolCaps(),
|
|
CapacityRange: &csi.CapacityRange{RequiredBytes: 4 * 1024 * 1024},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("create: %v", err)
|
|
}
|
|
|
|
if resp.Volume.VolumeContext == nil {
|
|
t.Fatal("BUG: volume_context is nil")
|
|
}
|
|
if resp.Volume.VolumeContext["iqn"] == "" {
|
|
t.Error("BUG: volume_context missing 'iqn'")
|
|
}
|
|
if resp.Volume.VolumeContext["iscsiAddr"] == "" {
|
|
t.Error("BUG: volume_context missing 'iscsiAddr'")
|
|
}
|
|
}
|
|
|
|
// QA-CTRL-B2: ValidateVolumeCapabilities via backend — should use LookupVolume.
|
|
func TestQA_Ctrl_ValidateUsesBackend(t *testing.T) {
|
|
mgr := newTestManager(t)
|
|
cs := &controllerServer{backend: NewLocalVolumeBackend(mgr)}
|
|
|
|
// Create a volume first.
|
|
_, err := cs.CreateVolume(context.Background(), &csi.CreateVolumeRequest{
|
|
Name: "validate-vol",
|
|
VolumeCapabilities: testVolCaps(),
|
|
CapacityRange: &csi.CapacityRange{RequiredBytes: 4 * 1024 * 1024},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("create: %v", err)
|
|
}
|
|
|
|
// Validate should succeed.
|
|
_, err = cs.ValidateVolumeCapabilities(context.Background(), &csi.ValidateVolumeCapabilitiesRequest{
|
|
VolumeId: "validate-vol",
|
|
VolumeCapabilities: []*csi.VolumeCapability{
|
|
{AccessType: &csi.VolumeCapability_Mount{Mount: &csi.VolumeCapability_MountVolume{}}},
|
|
},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("validate: %v", err)
|
|
}
|
|
|
|
// Delete the volume.
|
|
_, err = cs.DeleteVolume(context.Background(), &csi.DeleteVolumeRequest{VolumeId: "validate-vol"})
|
|
if err != nil {
|
|
t.Fatalf("delete: %v", err)
|
|
}
|
|
|
|
// Validate should now fail with NotFound.
|
|
_, err = cs.ValidateVolumeCapabilities(context.Background(), &csi.ValidateVolumeCapabilitiesRequest{
|
|
VolumeId: "validate-vol",
|
|
VolumeCapabilities: []*csi.VolumeCapability{
|
|
{AccessType: &csi.VolumeCapability_Mount{Mount: &csi.VolumeCapability_MountVolume{}}},
|
|
},
|
|
})
|
|
if err == nil {
|
|
t.Fatal("validate should fail after delete")
|
|
}
|
|
st, ok := status.FromError(err)
|
|
if !ok || st.Code() != codes.NotFound {
|
|
t.Fatalf("expected NotFound, got: %v", err)
|
|
}
|
|
}
|
|
|
|
// QA-CTRL-B3: CreateVolume then CreateVolume with LARGER size — backend should reject.
|
|
func TestQA_Ctrl_CreateLargerSizeRejected(t *testing.T) {
|
|
mgr := newTestManager(t)
|
|
cs := &controllerServer{backend: NewLocalVolumeBackend(mgr)}
|
|
|
|
_, err := cs.CreateVolume(context.Background(), &csi.CreateVolumeRequest{
|
|
Name: "grow-vol",
|
|
VolumeCapabilities: testVolCaps(),
|
|
CapacityRange: &csi.CapacityRange{RequiredBytes: 4 * 1024 * 1024},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("first create: %v", err)
|
|
}
|
|
|
|
// Second create with larger size.
|
|
_, err = cs.CreateVolume(context.Background(), &csi.CreateVolumeRequest{
|
|
Name: "grow-vol",
|
|
VolumeCapabilities: testVolCaps(),
|
|
CapacityRange: &csi.CapacityRange{RequiredBytes: 8 * 1024 * 1024},
|
|
})
|
|
if err == nil {
|
|
t.Fatal("expected error for larger size")
|
|
}
|
|
st, ok := status.FromError(err)
|
|
if !ok || st.Code() != codes.AlreadyExists {
|
|
t.Fatalf("expected AlreadyExists, got: %v", err)
|
|
}
|
|
}
|
|
|
|
// QA-CTRL-B4: CreateVolume with RequiredBytes exactly at blockSize boundary — no rounding needed.
|
|
func TestQA_Ctrl_ExactBlockSizeBoundary(t *testing.T) {
|
|
mgr := newTestManager(t)
|
|
cs := &controllerServer{backend: NewLocalVolumeBackend(mgr)}
|
|
|
|
resp, err := cs.CreateVolume(context.Background(), &csi.CreateVolumeRequest{
|
|
Name: "exact-vol",
|
|
VolumeCapabilities: testVolCaps(),
|
|
CapacityRange: &csi.CapacityRange{RequiredBytes: 4 * 1024 * 1024}, // exactly 4 MiB, aligned to 4096
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("create: %v", err)
|
|
}
|
|
if resp.Volume.CapacityBytes != 4*1024*1024 {
|
|
t.Errorf("capacity: got %d, want %d", resp.Volume.CapacityBytes, 4*1024*1024)
|
|
}
|
|
}
|
|
|
|
// QA-CTRL-B5: Concurrent CreateVolume calls for same name via backend.
|
|
func TestQA_Ctrl_ConcurrentCreate(t *testing.T) {
|
|
mgr := newTestManager(t)
|
|
cs := &controllerServer{backend: NewLocalVolumeBackend(mgr)}
|
|
|
|
var wg sync.WaitGroup
|
|
var panicked atomic.Bool
|
|
errors := make([]error, 10)
|
|
|
|
for i := 0; i < 10; i++ {
|
|
wg.Add(1)
|
|
go func(i int) {
|
|
defer wg.Done()
|
|
defer func() {
|
|
if r := recover(); r != nil {
|
|
panicked.Store(true)
|
|
t.Errorf("PANIC: %v", r)
|
|
}
|
|
}()
|
|
_, errors[i] = cs.CreateVolume(context.Background(), &csi.CreateVolumeRequest{
|
|
Name: "concurrent-vol",
|
|
VolumeCapabilities: testVolCaps(),
|
|
CapacityRange: &csi.CapacityRange{RequiredBytes: 4 * 1024 * 1024},
|
|
})
|
|
}(i)
|
|
}
|
|
wg.Wait()
|
|
|
|
if panicked.Load() {
|
|
t.Fatal("BUG: concurrent CreateVolume caused panic")
|
|
}
|
|
|
|
// All should succeed (idempotent).
|
|
for i, err := range errors {
|
|
if err != nil {
|
|
t.Errorf("goroutine %d: %v", i, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// ============================================================
|
|
// QA-BACKEND: LocalVolumeBackend adversarial tests
|
|
// ============================================================
|
|
|
|
// QA-BACKEND-1: LookupVolume for a volume that exists on disk but not in-memory (after restart).
|
|
func TestQA_Backend_LookupAfterRestart(t *testing.T) {
|
|
dir := t.TempDir()
|
|
logger := log.New(os.Stderr, "[test-qa] ", log.LstdFlags)
|
|
|
|
// Phase 1: create volume, stop manager.
|
|
mgr1 := NewVolumeManager(dir, "127.0.0.1:0", "iqn.test", logger)
|
|
mgr1.Start(context.Background())
|
|
mgr1.CreateVolume("orphan-vol", 4*1024*1024)
|
|
mgr1.Stop()
|
|
|
|
// Phase 2: new manager — volume exists on disk but not tracked.
|
|
mgr2 := NewVolumeManager(dir, "127.0.0.1:0", "iqn.test", logger)
|
|
mgr2.Start(context.Background())
|
|
defer mgr2.Stop()
|
|
|
|
backend := NewLocalVolumeBackend(mgr2)
|
|
|
|
// LookupVolume should fail (not tracked).
|
|
_, err := backend.LookupVolume(context.Background(), "orphan-vol")
|
|
if err == nil {
|
|
t.Fatal("BUG: LookupVolume should fail for untracked volume")
|
|
}
|
|
|
|
// CreateVolume should re-adopt from disk.
|
|
info, err := backend.CreateVolume(context.Background(), "orphan-vol", 4*1024*1024)
|
|
if err != nil {
|
|
t.Fatalf("re-adopt: %v", err)
|
|
}
|
|
if info.CapacityBytes < 4*1024*1024 {
|
|
t.Fatalf("capacity: got %d, want >= %d", info.CapacityBytes, 4*1024*1024)
|
|
}
|
|
|
|
// Now lookup should succeed.
|
|
_, err = backend.LookupVolume(context.Background(), "orphan-vol")
|
|
if err != nil {
|
|
t.Fatalf("lookup after re-adopt: %v", err)
|
|
}
|
|
}
|
|
|
|
// QA-BACKEND-2: DeleteVolume then LookupVolume — should fail.
|
|
func TestQA_Backend_DeleteThenLookup(t *testing.T) {
|
|
mgr := newTestManager(t)
|
|
backend := NewLocalVolumeBackend(mgr)
|
|
|
|
_, err := backend.CreateVolume(context.Background(), "del-vol", 4*1024*1024)
|
|
if err != nil {
|
|
t.Fatalf("create: %v", err)
|
|
}
|
|
|
|
err = backend.DeleteVolume(context.Background(), "del-vol")
|
|
if err != nil {
|
|
t.Fatalf("delete: %v", err)
|
|
}
|
|
|
|
_, err = backend.LookupVolume(context.Background(), "del-vol")
|
|
if err == nil {
|
|
t.Fatal("BUG: LookupVolume should fail after delete")
|
|
}
|
|
}
|
|
|
|
// ============================================================
|
|
// QA-NAMING: Cross-layer naming consistency
|
|
// ============================================================
|
|
|
|
// QA-NAMING-1: Verify IQN generated by VolumeManager matches IQN generated by BlockService.
|
|
// Both should use blockvol.SanitizeIQN.
|
|
func TestQA_Naming_CrossLayerConsistency(t *testing.T) {
|
|
testNames := []string{
|
|
"pvc-abc-123",
|
|
"VolA",
|
|
"has spaces",
|
|
"UPPER_CASE",
|
|
"special!@#$chars",
|
|
strings.Repeat("long-name-", 10), // 100 chars, triggers truncation
|
|
}
|
|
|
|
for _, name := range testNames {
|
|
// What VolumeManager would generate.
|
|
vmIQN := "iqn.2024.com.seaweedfs:" + blockvol.SanitizeIQN(name)
|
|
|
|
// What BlockService would generate (same prefix + SanitizeIQN).
|
|
bsIQN := "iqn.2024.com.seaweedfs:" + blockvol.SanitizeIQN(name)
|
|
|
|
if vmIQN != bsIQN {
|
|
t.Errorf("IQN mismatch for %q: VM=%q, BS=%q", name, vmIQN, bsIQN)
|
|
}
|
|
|
|
// Filename consistency.
|
|
vmFile := blockvol.SanitizeFilename(name) + ".blk"
|
|
bsFile := blockvol.SanitizeFilename(name) + ".blk"
|
|
if vmFile != bsFile {
|
|
t.Errorf("filename mismatch for %q: VM=%q, BS=%q", name, vmFile, bsFile)
|
|
}
|
|
}
|
|
}
|
|
|
|
// QA-NAMING-2: Two different names that produce the same sanitized IQN.
|
|
// This shouldn't happen for typical CSI volume IDs, but test the hash suffix behavior.
|
|
func TestQA_Naming_LongNameHashCollision(t *testing.T) {
|
|
// Two names that are identical for the first 55 chars but differ at the end.
|
|
// Both exceed 64 chars, so they get truncated with hash suffix.
|
|
name1 := strings.Repeat("a", 55) + "-suffix-one"
|
|
name2 := strings.Repeat("a", 55) + "-suffix-two"
|
|
|
|
iqn1 := blockvol.SanitizeIQN(name1)
|
|
iqn2 := blockvol.SanitizeIQN(name2)
|
|
|
|
if iqn1 == iqn2 {
|
|
t.Errorf("BUG: different names produced same IQN (hash collision):\n name1=%q\n name2=%q\n iqn=%q", name1, name2, iqn1)
|
|
}
|
|
|
|
// Both should be <= 64 chars.
|
|
if len(iqn1) > 64 {
|
|
t.Errorf("iqn1 too long: %d", len(iqn1))
|
|
}
|
|
if len(iqn2) > 64 {
|
|
t.Errorf("iqn2 too long: %d", len(iqn2))
|
|
}
|
|
}
|
|
|
|
// ============================================================
|
|
// QA-LIFECYCLE: End-to-end lifecycle with remote targets
|
|
// ============================================================
|
|
|
|
// QA-LIFECYCLE-1: Full remote lifecycle: create → stage (remote) → publish → unpublish → unstage → delete.
|
|
func TestQA_RemoteLifecycleFull(t *testing.T) {
|
|
dir := t.TempDir()
|
|
logger := log.New(os.Stderr, "[test-qa] ", log.LstdFlags)
|
|
mgr := NewVolumeManager(dir, "127.0.0.1:0", "iqn.2024.com.seaweedfs", logger)
|
|
if err := mgr.Start(context.Background()); err != nil {
|
|
t.Fatalf("start: %v", err)
|
|
}
|
|
defer mgr.Stop()
|
|
|
|
mi := newMockISCSIUtil()
|
|
mi.getDeviceResult = "/dev/sda"
|
|
mm := newMockMountUtil()
|
|
|
|
cs := &controllerServer{backend: NewLocalVolumeBackend(mgr)}
|
|
ns := &nodeServer{
|
|
mgr: nil, // simulate node-only mode (no local mgr)
|
|
nodeID: "test-node",
|
|
iqnPrefix: "iqn.2024.com.seaweedfs",
|
|
iscsiUtil: mi,
|
|
mountUtil: mm,
|
|
logger: logger,
|
|
staged: make(map[string]*stagedVolumeInfo),
|
|
}
|
|
|
|
// Create volume via controller (this creates it locally, but we'll use it as if remote).
|
|
createResp, err := cs.CreateVolume(context.Background(), &csi.CreateVolumeRequest{
|
|
Name: "remote-life-vol",
|
|
VolumeCapabilities: testVolCaps(),
|
|
CapacityRange: &csi.CapacityRange{RequiredBytes: 4 * 1024 * 1024},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("create: %v", err)
|
|
}
|
|
|
|
// Stage using volume_context (simulating remote node).
|
|
stagingPath := filepath.Join(t.TempDir(), "staging")
|
|
targetPath := filepath.Join(t.TempDir(), "target")
|
|
|
|
_, err = ns.NodeStageVolume(context.Background(), &csi.NodeStageVolumeRequest{
|
|
VolumeId: "remote-life-vol",
|
|
StagingTargetPath: stagingPath,
|
|
VolumeCapability: testVolCap(),
|
|
VolumeContext: createResp.Volume.VolumeContext,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("stage: %v", err)
|
|
}
|
|
|
|
// Publish.
|
|
_, err = ns.NodePublishVolume(context.Background(), &csi.NodePublishVolumeRequest{
|
|
VolumeId: "remote-life-vol",
|
|
StagingTargetPath: stagingPath,
|
|
TargetPath: targetPath,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("publish: %v", err)
|
|
}
|
|
|
|
// Unpublish.
|
|
_, err = ns.NodeUnpublishVolume(context.Background(), &csi.NodeUnpublishVolumeRequest{
|
|
VolumeId: "remote-life-vol",
|
|
TargetPath: targetPath,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("unpublish: %v", err)
|
|
}
|
|
|
|
// Unstage.
|
|
_, err = ns.NodeUnstageVolume(context.Background(), &csi.NodeUnstageVolumeRequest{
|
|
VolumeId: "remote-life-vol",
|
|
StagingTargetPath: stagingPath,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("unstage: %v", err)
|
|
}
|
|
|
|
// Delete.
|
|
_, err = cs.DeleteVolume(context.Background(), &csi.DeleteVolumeRequest{
|
|
VolumeId: "remote-life-vol",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("delete: %v", err)
|
|
}
|
|
|
|
// Verify file is gone.
|
|
volPath := filepath.Join(dir, sanitizeFilename("remote-life-vol")+".blk")
|
|
if _, statErr := os.Stat(volPath); !os.IsNotExist(statErr) {
|
|
t.Errorf(".blk file not cleaned up: %v", statErr)
|
|
}
|
|
}
|
|
|
|
// QA-LIFECYCLE-2: Mode validation — controller mode should not need local VolumeManager.
|
|
func TestQA_ModeControllerNoMgr(t *testing.T) {
|
|
// In controller mode with masterAddr, no local VolumeManager is needed.
|
|
// We can't test MasterVolumeClient without a real master, but we can verify
|
|
// that the driver setup logic is correct.
|
|
_, err := NewCSIDriver(DriverConfig{
|
|
Endpoint: "unix:///tmp/test.sock",
|
|
DataDir: t.TempDir(),
|
|
NodeID: "test-node",
|
|
MasterAddr: "master:9333",
|
|
Mode: "controller",
|
|
})
|
|
if err != nil {
|
|
// MasterVolumeClient creation should work (connection is lazy).
|
|
// If this fails, it means the driver config validation is wrong.
|
|
t.Logf("NewCSIDriver controller mode: %v (may fail without grpc deps, OK)", err)
|
|
}
|
|
}
|
|
|
|
// QA-LIFECYCLE-3: Driver with mode "node" should not create controller service.
|
|
func TestQA_ModeNodeOnly(t *testing.T) {
|
|
driver, err := NewCSIDriver(DriverConfig{
|
|
Endpoint: "unix:///tmp/test-node.sock",
|
|
DataDir: t.TempDir(),
|
|
NodeID: "test-node",
|
|
Mode: "node",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("NewCSIDriver node mode: %v", err)
|
|
}
|
|
|
|
// In node mode, controller should be nil.
|
|
if driver.controller != nil {
|
|
t.Error("BUG: controller should be nil in node mode")
|
|
}
|
|
// Node should be non-nil.
|
|
if driver.node == nil {
|
|
t.Error("BUG: node should be non-nil in node mode")
|
|
}
|
|
}
|
|
|
|
// QA-LIFECYCLE-4: Driver with invalid mode should error.
|
|
func TestQA_ModeInvalid(t *testing.T) {
|
|
_, err := NewCSIDriver(DriverConfig{
|
|
Endpoint: "unix:///tmp/test.sock",
|
|
DataDir: t.TempDir(),
|
|
NodeID: "test-node",
|
|
Mode: "invalid",
|
|
})
|
|
if err == nil {
|
|
t.Fatal("expected error for invalid mode")
|
|
}
|
|
}
|
|
|
|
// ============================================================
|
|
// QA-SERVER: Server/Driver configuration adversarial tests
|
|
// ============================================================
|
|
|
|
// QA-SRV-CP62-1: DriverConfig with mode="all" and no masterAddr uses local backend.
|
|
func TestQA_Srv_AllModeLocalBackend(t *testing.T) {
|
|
driver, err := NewCSIDriver(DriverConfig{
|
|
Endpoint: "unix:///tmp/test-all.sock",
|
|
DataDir: t.TempDir(),
|
|
NodeID: "test-node",
|
|
Mode: "all",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("NewCSIDriver: %v", err)
|
|
}
|
|
if driver.controller == nil {
|
|
t.Error("controller should be non-nil in 'all' mode")
|
|
}
|
|
if driver.node == nil {
|
|
t.Error("node should be non-nil in 'all' mode")
|
|
}
|
|
if driver.mgr == nil {
|
|
t.Error("mgr should be non-nil when no masterAddr and mode is 'all'")
|
|
}
|
|
}
|
|
|
|
// QA-SRV-CP62-2: Multiple calls to driver.Stop() should not panic.
|
|
func TestQA_Srv_DoubleStop(t *testing.T) {
|
|
driver, err := NewCSIDriver(DriverConfig{
|
|
Endpoint: "unix:///tmp/test-stop.sock",
|
|
DataDir: t.TempDir(),
|
|
NodeID: "test-node",
|
|
Mode: "all",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("NewCSIDriver: %v", err)
|
|
}
|
|
|
|
defer func() {
|
|
if r := recover(); r != nil {
|
|
t.Fatalf("BUG: double Stop() panicked: %v", r)
|
|
}
|
|
}()
|
|
|
|
driver.Stop()
|
|
driver.Stop() // should not panic
|
|
}
|
|
|
|
// ============================================================
|
|
// QA-VM-CP62: VolumeManager adversarial tests (CP6-2 additions)
|
|
// ============================================================
|
|
|
|
// QA-VM-CP62-1: CreateVolume after Stop without Start — should return ErrNotReady.
|
|
func TestQA_VM_CreateAfterStop(t *testing.T) {
|
|
dir := t.TempDir()
|
|
logger := log.New(os.Stderr, "[test-qa] ", log.LstdFlags)
|
|
mgr := NewVolumeManager(dir, "127.0.0.1:0", "iqn.test", logger)
|
|
mgr.Start(context.Background())
|
|
mgr.Stop()
|
|
|
|
err := mgr.CreateVolume("vol1", 4*1024*1024)
|
|
if !errors.Is(err, ErrNotReady) {
|
|
t.Fatalf("expected ErrNotReady after Stop, got: %v", err)
|
|
}
|
|
}
|
|
|
|
// QA-VM-CP62-2: OpenVolume on a volume that doesn't exist on disk.
|
|
func TestQA_VM_OpenNonExistent(t *testing.T) {
|
|
mgr := newTestManager(t)
|
|
err := mgr.OpenVolume("does-not-exist")
|
|
if err == nil {
|
|
t.Fatal("OpenVolume for non-existent should fail")
|
|
}
|
|
}
|
|
|
|
// QA-VM-CP62-3: ListenAddr returns empty string after Stop.
|
|
func TestQA_VM_ListenAddrAfterStop(t *testing.T) {
|
|
dir := t.TempDir()
|
|
logger := log.New(os.Stderr, "[test-qa] ", log.LstdFlags)
|
|
mgr := NewVolumeManager(dir, "127.0.0.1:0", "iqn.test", logger)
|
|
mgr.Start(context.Background())
|
|
|
|
addr := mgr.ListenAddr()
|
|
if addr == "" {
|
|
t.Fatal("ListenAddr should be non-empty while running")
|
|
}
|
|
|
|
mgr.Stop()
|
|
|
|
addr = mgr.ListenAddr()
|
|
if addr != "" {
|
|
t.Logf("ListenAddr after Stop: %q (may return stale addr, not a bug if documented)", addr)
|
|
}
|
|
}
|
|
|
|
// QA-VM-CP62-4: VolumeIQN uses shared sanitization.
|
|
func TestQA_VM_VolumeIQNSanitized(t *testing.T) {
|
|
mgr := newTestManager(t)
|
|
|
|
iqn := mgr.VolumeIQN("pvc-ABC/def:123")
|
|
expected := "iqn.2024.com.seaweedfs:" + blockvol.SanitizeIQN("pvc-ABC/def:123")
|
|
if iqn != expected {
|
|
t.Errorf("VolumeIQN: got %q, want %q", iqn, expected)
|
|
}
|
|
|
|
// Should be lowercase.
|
|
if strings.ToLower(iqn) != iqn {
|
|
t.Errorf("VolumeIQN not fully lowercase: %q", iqn)
|
|
}
|
|
}
|
|
|
|
// ============================================================
|
|
// QA-EDGE: Edge case tests
|
|
// ============================================================
|
|
|
|
// QA-EDGE-1: CreateVolume with RequiredBytes = minVolumeSizeBytes (1 MiB) exactly.
|
|
func TestQA_Edge_MinSize(t *testing.T) {
|
|
mgr := newTestManager(t)
|
|
cs := &controllerServer{backend: NewLocalVolumeBackend(mgr)}
|
|
|
|
resp, err := cs.CreateVolume(context.Background(), &csi.CreateVolumeRequest{
|
|
Name: "min-vol",
|
|
VolumeCapabilities: testVolCaps(),
|
|
CapacityRange: &csi.CapacityRange{RequiredBytes: 1 << 20}, // exactly 1 MiB
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("create: %v", err)
|
|
}
|
|
if resp.Volume.CapacityBytes < 1<<20 {
|
|
t.Errorf("capacity too small: got %d, want >= %d", resp.Volume.CapacityBytes, 1<<20)
|
|
}
|
|
}
|
|
|
|
// QA-EDGE-2: CreateVolume with RequiredBytes just below minVolumeSizeBytes.
|
|
func TestQA_Edge_BelowMinSize(t *testing.T) {
|
|
mgr := newTestManager(t)
|
|
cs := &controllerServer{backend: NewLocalVolumeBackend(mgr)}
|
|
|
|
resp, err := cs.CreateVolume(context.Background(), &csi.CreateVolumeRequest{
|
|
Name: "sub-min-vol",
|
|
VolumeCapabilities: testVolCaps(),
|
|
CapacityRange: &csi.CapacityRange{RequiredBytes: 100}, // 100 bytes, below min
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("create: %v", err)
|
|
}
|
|
// Should be rounded up to minVolumeSizeBytes.
|
|
if resp.Volume.CapacityBytes < 1<<20 {
|
|
t.Errorf("capacity: got %d, expected >= minVolumeSizeBytes (1 MiB)", resp.Volume.CapacityBytes)
|
|
}
|
|
}
|
|
|
|
// QA-EDGE-3: CreateVolume with RequiredBytes = LimitBytes (exactly equal).
|
|
func TestQA_Edge_RequiredEqualsLimit(t *testing.T) {
|
|
mgr := newTestManager(t)
|
|
cs := &controllerServer{backend: NewLocalVolumeBackend(mgr)}
|
|
|
|
size := int64(4 * 1024 * 1024)
|
|
resp, err := cs.CreateVolume(context.Background(), &csi.CreateVolumeRequest{
|
|
Name: "exact-limit-vol",
|
|
VolumeCapabilities: testVolCaps(),
|
|
CapacityRange: &csi.CapacityRange{
|
|
RequiredBytes: size,
|
|
LimitBytes: size,
|
|
},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("create: %v", err)
|
|
}
|
|
if resp.Volume.CapacityBytes != size {
|
|
t.Errorf("capacity: got %d, want %d", resp.Volume.CapacityBytes, size)
|
|
}
|
|
}
|
|
|
|
// QA-EDGE-4: CreateVolume with RequiredBytes = 4097 (needs rounding to 8192).
|
|
// With LimitBytes = 4097, the rounded size exceeds LimitBytes — should fail.
|
|
func TestQA_Edge_RoundingExceedsLimit(t *testing.T) {
|
|
mgr := newTestManager(t)
|
|
cs := &controllerServer{backend: NewLocalVolumeBackend(mgr)}
|
|
|
|
_, err := cs.CreateVolume(context.Background(), &csi.CreateVolumeRequest{
|
|
Name: "round-exceed",
|
|
VolumeCapabilities: testVolCaps(),
|
|
CapacityRange: &csi.CapacityRange{
|
|
RequiredBytes: 4097,
|
|
LimitBytes: 4097,
|
|
},
|
|
})
|
|
// RequiredBytes=4097, rounds to 8192, but LimitBytes=4097 < 8192.
|
|
// However, since RequiredBytes < minVolumeSizeBytes (1 MiB), it gets bumped to 1 MiB.
|
|
// LimitBytes=4097 < 1 MiB, so sizeBytes (1 MiB) > LimitBytes → should fail.
|
|
if err == nil {
|
|
t.Fatal("expected error: rounded size exceeds LimitBytes")
|
|
}
|
|
st, ok := status.FromError(err)
|
|
if !ok || st.Code() != codes.InvalidArgument {
|
|
t.Fatalf("expected InvalidArgument, got: %v", err)
|
|
}
|
|
}
|
|
|
|
// QA-EDGE-5: Empty string volume name in node operations.
|
|
func TestQA_Edge_EmptyVolumeIDNode(t *testing.T) {
|
|
ns, _, _ := newTestNodeServer(t)
|
|
|
|
_, err := ns.NodeStageVolume(context.Background(), &csi.NodeStageVolumeRequest{
|
|
VolumeId: "",
|
|
StagingTargetPath: "/tmp/staging",
|
|
})
|
|
if err == nil {
|
|
t.Error("stage with empty volumeID should fail")
|
|
}
|
|
|
|
_, err = ns.NodeUnstageVolume(context.Background(), &csi.NodeUnstageVolumeRequest{
|
|
VolumeId: "",
|
|
StagingTargetPath: "/tmp/staging",
|
|
})
|
|
if err == nil {
|
|
t.Error("unstage with empty volumeID should fail")
|
|
}
|
|
|
|
_, err = ns.NodePublishVolume(context.Background(), &csi.NodePublishVolumeRequest{
|
|
VolumeId: "",
|
|
TargetPath: "/tmp/target",
|
|
})
|
|
if err == nil {
|
|
t.Error("publish with empty volumeID should fail")
|
|
}
|
|
|
|
_, err = ns.NodeUnpublishVolume(context.Background(), &csi.NodeUnpublishVolumeRequest{
|
|
VolumeId: "",
|
|
TargetPath: "/tmp/target",
|
|
})
|
|
if err == nil {
|
|
t.Error("unpublish with empty volumeID should fail")
|
|
}
|
|
}
|
|
|
|
// QA-EDGE-6: Sanitization: volume name with only dots.
|
|
func TestQA_Edge_AllDotsName(t *testing.T) {
|
|
name := "..."
|
|
iqn := blockvol.SanitizeIQN(name)
|
|
file := blockvol.SanitizeFilename(name)
|
|
|
|
if iqn == "" {
|
|
t.Error("SanitizeIQN('...') should not be empty")
|
|
}
|
|
if file == "" {
|
|
t.Error("SanitizeFilename('...') should not be empty")
|
|
}
|
|
t.Logf("SanitizeIQN('...')=%q, SanitizeFilename('...')=%q", iqn, file)
|
|
|
|
// The filename "..." -> "..." (dots are valid). Check it doesn't create
|
|
// a directory traversal.
|
|
if strings.Contains(file, "..") {
|
|
t.Logf("WARNING: sanitized filename contains '..': %q (could be path traversal in filepath.Join)", file)
|
|
}
|
|
}
|
|
|
|
// QA-EDGE-7: Large number of volumes registered, then full heartbeat reconcile.
|
|
func TestQA_Edge_LargeScaleHeartbeat(t *testing.T) {
|
|
mgr := newTestManager(t)
|
|
|
|
// Create 100 volumes.
|
|
for i := 0; i < 100; i++ {
|
|
name := fmt.Sprintf("scale-vol-%d", i)
|
|
if err := mgr.CreateVolume(name, 1<<20); err != nil { // 1 MiB each
|
|
t.Fatalf("create %s: %v", name, err)
|
|
}
|
|
}
|
|
|
|
// Verify all exist.
|
|
for i := 0; i < 100; i++ {
|
|
if !mgr.VolumeExists(fmt.Sprintf("scale-vol-%d", i)) {
|
|
t.Fatalf("scale-vol-%d not found", i)
|
|
}
|
|
}
|
|
|
|
// Delete all.
|
|
for i := 0; i < 100; i++ {
|
|
mgr.DeleteVolume(fmt.Sprintf("scale-vol-%d", i))
|
|
}
|
|
|
|
// Verify all gone.
|
|
for i := 0; i < 100; i++ {
|
|
if mgr.VolumeExists(fmt.Sprintf("scale-vol-%d", i)) {
|
|
t.Fatalf("scale-vol-%d still exists after delete", i)
|
|
}
|
|
}
|
|
}
|