Files
seaweedfs/weed/storage/blockvol/csi/qa_cp62_test.go
T
Ping QiuandClaude Opus 4.6 bbadeeb89b feat: Phase 10 CP10-2 -- CSI NVMe/TCP node plugin, 210 tests
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>
2026-03-08 23:02:59 -07:00

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)
}
}
}