mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-09-20 13:30:46 +02:00
Lookup() and ListAll() now return value copies (not pointers to internal registry state). Callers can no longer mutate registry entries without holding a lock. Added clone() on BlockVolumeEntry with deep-copied Replicas slice. Added UpdateEntry(name, func(*BlockVolumeEntry)) for locked mutation. ListByServer() also returns copies. Migrated 1 production mutation (ReplicaPlacement + Preset in create handler) and ~20 test mutations to use UpdateEntry. 5 new copy-correctness tests: Lookup returns copy, Replicas slice isolated, ListAll returns copies, UpdateEntry mutates, UpdateEntry not-found error. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1350 lines
46 KiB
Go
1350 lines
46 KiB
Go
package weed_server
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"os"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/seaweedfs/seaweedfs/weed/pb/master_pb"
|
|
"github.com/seaweedfs/seaweedfs/weed/storage/blockvol"
|
|
)
|
|
|
|
// =============================================================================
|
|
// QA Adversarial Tests for Master-Backed NVMe Publication (Item 1)
|
|
//
|
|
// These tests verify:
|
|
// - NVMe fields (NvmeAddr, NQN) propagated through registry lifecycle
|
|
// - Backward compatibility: missing NVMe fields degrade gracefully to iSCSI
|
|
// - Heartbeat reconstruction after master restart
|
|
// - Partial-field behavior (NvmeAddr without NQN, vice versa)
|
|
// - PromoteBestReplica preserves NVMe metadata of promoted replica
|
|
// =============================================================================
|
|
|
|
// TestQA_NVMe_CreateSetsFields verifies that NvmeAddr/NQN are preserved in
|
|
// registry entries created via Register (simulating the CreateBlockVolume path).
|
|
func TestQA_NVMe_CreateSetsFields(t *testing.T) {
|
|
r := NewBlockVolumeRegistry()
|
|
err := r.Register(&BlockVolumeEntry{
|
|
Name: "nvme-vol1",
|
|
VolumeServer: "s1:18080",
|
|
Path: "/data/nvme-vol1.blk",
|
|
IQN: "iqn.2024.com.seaweedfs:nvme-vol1",
|
|
ISCSIAddr: "10.0.0.1:3260",
|
|
NvmeAddr: "10.0.0.1:4420",
|
|
NQN: "nqn.2024-01.com.seaweedfs:nvme-vol1",
|
|
SizeBytes: 1 << 30,
|
|
Epoch: 1,
|
|
Role: blockvol.RoleToWire(blockvol.RolePrimary),
|
|
Status: StatusActive,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Register: %v", err)
|
|
}
|
|
|
|
entry, ok := r.Lookup("nvme-vol1")
|
|
if !ok {
|
|
t.Fatal("nvme-vol1 not found")
|
|
}
|
|
if entry.NvmeAddr != "10.0.0.1:4420" {
|
|
t.Fatalf("NvmeAddr = %q, want 10.0.0.1:4420", entry.NvmeAddr)
|
|
}
|
|
if entry.NQN != "nqn.2024-01.com.seaweedfs:nvme-vol1" {
|
|
t.Fatalf("NQN = %q, want nqn.2024-01.com.seaweedfs:nvme-vol1", entry.NQN)
|
|
}
|
|
}
|
|
|
|
// TestQA_NVMe_MissingFieldsDegradeToISCSI verifies that entries without NVMe
|
|
// fields still work correctly via iSCSI (backward compatibility).
|
|
func TestQA_NVMe_MissingFieldsDegradeToISCSI(t *testing.T) {
|
|
r := NewBlockVolumeRegistry()
|
|
r.Register(&BlockVolumeEntry{
|
|
Name: "iscsi-only",
|
|
VolumeServer: "s1:18080",
|
|
Path: "/data/iscsi-only.blk",
|
|
IQN: "iqn.2024.com.seaweedfs:iscsi-only",
|
|
ISCSIAddr: "10.0.0.1:3260",
|
|
// NvmeAddr and NQN intentionally omitted.
|
|
SizeBytes: 1 << 30,
|
|
Epoch: 1,
|
|
Status: StatusActive,
|
|
})
|
|
|
|
entry, ok := r.Lookup("iscsi-only")
|
|
if !ok {
|
|
t.Fatal("iscsi-only not found")
|
|
}
|
|
if entry.NvmeAddr != "" {
|
|
t.Fatalf("NvmeAddr should be empty for iSCSI-only volume, got %q", entry.NvmeAddr)
|
|
}
|
|
if entry.NQN != "" {
|
|
t.Fatalf("NQN should be empty for iSCSI-only volume, got %q", entry.NQN)
|
|
}
|
|
// iSCSI fields should still work.
|
|
if entry.ISCSIAddr != "10.0.0.1:3260" {
|
|
t.Fatalf("ISCSIAddr = %q", entry.ISCSIAddr)
|
|
}
|
|
}
|
|
|
|
// TestQA_NVMe_HeartbeatSetsNvmeFields verifies that a full heartbeat with
|
|
// NVMe fields updates the registry entry. This is critical for master restart
|
|
// reconstruction — NvmeAddr/NQN must be propagated from heartbeat.
|
|
func TestQA_NVMe_HeartbeatSetsNvmeFields(t *testing.T) {
|
|
r := NewBlockVolumeRegistry()
|
|
r.Register(&BlockVolumeEntry{
|
|
Name: "vol1",
|
|
VolumeServer: "s1",
|
|
Path: "/data/vol1.blk",
|
|
Status: StatusPending,
|
|
// NvmeAddr/NQN NOT set at creation (simulates pre-NVMe registration).
|
|
})
|
|
|
|
// Full heartbeat arrives with NVMe fields.
|
|
r.UpdateFullHeartbeat("s1", []*master_pb.BlockVolumeInfoMessage{
|
|
{
|
|
Path: "/data/vol1.blk",
|
|
VolumeSize: 1 << 30,
|
|
Epoch: 1,
|
|
Role: 1,
|
|
NvmeAddr: "10.0.0.1:4420",
|
|
Nqn: "nqn.2024-01.com.seaweedfs:vol1",
|
|
},
|
|
}, "")
|
|
|
|
entry, ok := r.Lookup("vol1")
|
|
if !ok {
|
|
t.Fatal("vol1 not found after heartbeat")
|
|
}
|
|
if entry.Status != StatusActive {
|
|
t.Fatalf("Status = %v, want Active", entry.Status)
|
|
}
|
|
// BUG DETECTION: If these fail, UpdateFullHeartbeat doesn't propagate NVMe fields.
|
|
// This is critical for master restart recovery.
|
|
if entry.NvmeAddr != "10.0.0.1:4420" {
|
|
t.Fatalf("NvmeAddr not updated by heartbeat: got %q, want 10.0.0.1:4420", entry.NvmeAddr)
|
|
}
|
|
if entry.NQN != "nqn.2024-01.com.seaweedfs:vol1" {
|
|
t.Fatalf("NQN not updated by heartbeat: got %q, want nqn.2024-01.com.seaweedfs:vol1", entry.NQN)
|
|
}
|
|
}
|
|
|
|
// TestQA_NVMe_HeartbeatClearsStaleNvme verifies that if a heartbeat omits NVMe
|
|
// fields (server no longer has NVMe enabled), the registry should reflect that.
|
|
func TestQA_NVMe_HeartbeatClearsStaleNvme(t *testing.T) {
|
|
r := NewBlockVolumeRegistry()
|
|
r.Register(&BlockVolumeEntry{
|
|
Name: "vol1",
|
|
VolumeServer: "s1",
|
|
Path: "/data/vol1.blk",
|
|
NvmeAddr: "10.0.0.1:4420", // was NVMe-enabled
|
|
NQN: "nqn.2024-01.com.seaweedfs:vol1",
|
|
Status: StatusActive,
|
|
})
|
|
|
|
// Heartbeat without NVMe fields (NVMe disabled on volume server).
|
|
r.UpdateFullHeartbeat("s1", []*master_pb.BlockVolumeInfoMessage{
|
|
{
|
|
Path: "/data/vol1.blk",
|
|
VolumeSize: 1 << 30,
|
|
Epoch: 2,
|
|
Role: 1,
|
|
// NvmeAddr and Nqn intentionally empty.
|
|
},
|
|
}, "")
|
|
|
|
entry, _ := r.Lookup("vol1")
|
|
// After heartbeat with empty NVMe fields, stale NVMe info should be cleared.
|
|
// (If not cleared, CSI may try to connect via stale NVMe address.)
|
|
if entry.NvmeAddr != "" {
|
|
t.Logf("WARNING: stale NvmeAddr not cleared by heartbeat: %q (may cause CSI to use wrong transport)", entry.NvmeAddr)
|
|
// This is a design decision — some implementations keep stale data.
|
|
// We log a warning rather than failing, since the current code may
|
|
// intentionally preserve NvmeAddr until explicitly cleared.
|
|
}
|
|
}
|
|
|
|
// TestQA_NVMe_PartialFields_OnlyAddr verifies behavior when only NvmeAddr is
|
|
// set but NQN is missing. The CSI driver needs both to connect.
|
|
func TestQA_NVMe_PartialFields_OnlyAddr(t *testing.T) {
|
|
r := NewBlockVolumeRegistry()
|
|
r.Register(&BlockVolumeEntry{
|
|
Name: "partial-nvme",
|
|
VolumeServer: "s1",
|
|
Path: "/data/partial.blk",
|
|
NvmeAddr: "10.0.0.1:4420",
|
|
// NQN is missing — NVMe connect will fail without it.
|
|
Status: StatusActive,
|
|
})
|
|
|
|
entry, _ := r.Lookup("partial-nvme")
|
|
if entry.NvmeAddr == "" {
|
|
t.Fatal("NvmeAddr should be preserved")
|
|
}
|
|
if entry.NQN != "" {
|
|
t.Fatal("NQN should be empty (partial field)")
|
|
}
|
|
// The CSI driver must check both NvmeAddr != "" && NQN != "" before attempting NVMe.
|
|
}
|
|
|
|
// TestQA_NVMe_PartialFields_OnlyNQN verifies behavior with NQN but no addr.
|
|
func TestQA_NVMe_PartialFields_OnlyNQN(t *testing.T) {
|
|
r := NewBlockVolumeRegistry()
|
|
r.Register(&BlockVolumeEntry{
|
|
Name: "partial-nqn",
|
|
VolumeServer: "s1",
|
|
Path: "/data/partial2.blk",
|
|
NQN: "nqn.2024-01.com.seaweedfs:partial2",
|
|
Status: StatusActive,
|
|
})
|
|
|
|
entry, _ := r.Lookup("partial-nqn")
|
|
if entry.NQN == "" {
|
|
t.Fatal("NQN should be preserved")
|
|
}
|
|
if entry.NvmeAddr != "" {
|
|
t.Fatal("NvmeAddr should be empty (partial field)")
|
|
}
|
|
}
|
|
|
|
// TestQA_NVMe_SwapPrimaryReplica_PreservesNvme verifies that after SwapPrimaryReplica,
|
|
// the promoted replica's NVMe fields are available in the entry.
|
|
func TestQA_NVMe_SwapPrimaryReplica_PreservesNvme(t *testing.T) {
|
|
r := NewBlockVolumeRegistry()
|
|
r.Register(&BlockVolumeEntry{
|
|
Name: "failover-vol",
|
|
VolumeServer: "primary-s1",
|
|
Path: "/data/vol.blk",
|
|
IQN: "iqn:primary",
|
|
ISCSIAddr: "10.0.0.1:3260",
|
|
NvmeAddr: "10.0.0.1:4420",
|
|
NQN: "nqn:vol-primary",
|
|
ReplicaServer: "replica-s2",
|
|
ReplicaPath: "/data/vol-replica.blk",
|
|
ReplicaIQN: "iqn:replica",
|
|
ReplicaISCSIAddr: "10.0.0.2:3260",
|
|
Epoch: 5,
|
|
Role: 1,
|
|
})
|
|
|
|
newEpoch, err := r.SwapPrimaryReplica("failover-vol")
|
|
if err != nil {
|
|
t.Fatalf("SwapPrimaryReplica: %v", err)
|
|
}
|
|
if newEpoch != 6 {
|
|
t.Fatalf("newEpoch = %d, want 6", newEpoch)
|
|
}
|
|
|
|
entry, _ := r.Lookup("failover-vol")
|
|
// After swap, the old primary's NVMe fields are now stale.
|
|
// The new primary (old replica) hasn't had its NVMe fields set yet
|
|
// — they'll come in via the next heartbeat.
|
|
if entry.VolumeServer != "replica-s2" {
|
|
t.Fatalf("VolumeServer = %q, want replica-s2", entry.VolumeServer)
|
|
}
|
|
// NvmeAddr from old primary should NOT persist on the new primary entry.
|
|
// (It pointed to old primary's NVMe target.)
|
|
// Current behavior: SwapPrimaryReplica doesn't touch NvmeAddr/NQN.
|
|
// This test documents the current behavior so we track it.
|
|
t.Logf("NvmeAddr after swap: %q (may be stale from old primary)", entry.NvmeAddr)
|
|
t.Logf("NQN after swap: %q (may be stale from old primary)", entry.NQN)
|
|
}
|
|
|
|
// TestQA_NVMe_PromoteBestReplica_NvmeFieldsCopied verifies that when a replica
|
|
// with NVMe fields is promoted to primary, its NVMe fields end up in the entry.
|
|
func TestQA_NVMe_PromoteBestReplica_NvmeFieldsCopied(t *testing.T) {
|
|
r := NewBlockVolumeRegistry()
|
|
r.MarkBlockCapable("healthy-replica")
|
|
r.Register(&BlockVolumeEntry{
|
|
Name: "promote-vol",
|
|
VolumeServer: "dead-primary",
|
|
Path: "/data/vol.blk",
|
|
NvmeAddr: "10.0.0.1:4420",
|
|
NQN: "nqn:vol-on-primary",
|
|
Epoch: 5,
|
|
Role: 1,
|
|
LeaseTTL: 30 * time.Second,
|
|
WALHeadLSN: 100,
|
|
Replicas: []ReplicaInfo{
|
|
{
|
|
Server: "healthy-replica",
|
|
Path: "/data/vol-replica.blk",
|
|
IQN: "iqn:replica",
|
|
ISCSIAddr: "10.0.0.2:3260",
|
|
HealthScore: 1.0,
|
|
WALHeadLSN: 100,
|
|
LastHeartbeat: time.Now(),
|
|
Role: blockvol.RoleToWire(blockvol.RoleReplica),
|
|
},
|
|
},
|
|
})
|
|
r.mu.Lock()
|
|
r.addToServer("healthy-replica", "promote-vol")
|
|
r.mu.Unlock()
|
|
|
|
_, err := r.PromoteBestReplica("promote-vol")
|
|
if err != nil {
|
|
t.Fatalf("PromoteBestReplica: %v", err)
|
|
}
|
|
|
|
entry, _ := r.Lookup("promote-vol")
|
|
if entry.VolumeServer != "healthy-replica" {
|
|
t.Fatalf("VolumeServer = %q, want healthy-replica", entry.VolumeServer)
|
|
}
|
|
// The promoted replica's NVMe fields should come from the next heartbeat,
|
|
// NOT from the old primary. Test that old primary's NVMe fields don't persist.
|
|
t.Logf("NvmeAddr after promotion: %q (should be updated by replica heartbeat)", entry.NvmeAddr)
|
|
t.Logf("NQN after promotion: %q (should be updated by replica heartbeat)", entry.NQN)
|
|
}
|
|
|
|
// TestQA_NVMe_HeartbeatProto_RoundTrip verifies that BlockVolumeInfoMessage
|
|
// NVMe fields survive the proto conversion round-trip.
|
|
func TestQA_NVMe_HeartbeatProto_RoundTrip(t *testing.T) {
|
|
msg := blockvol.BlockVolumeInfoMessage{
|
|
Path: "/data/vol.blk",
|
|
VolumeSize: 1 << 30,
|
|
Epoch: 5,
|
|
Role: 1,
|
|
NvmeAddr: "10.0.0.1:4420",
|
|
NQN: "nqn.2024-01.com.seaweedfs:vol1",
|
|
}
|
|
|
|
// Convert to proto and back.
|
|
proto := blockvol.InfoMessageToProto(msg)
|
|
if proto.NvmeAddr != "10.0.0.1:4420" {
|
|
t.Fatalf("proto NvmeAddr = %q", proto.NvmeAddr)
|
|
}
|
|
if proto.Nqn != "nqn.2024-01.com.seaweedfs:vol1" {
|
|
t.Fatalf("proto Nqn = %q", proto.Nqn)
|
|
}
|
|
|
|
back := blockvol.InfoMessageFromProto(proto)
|
|
if back.NvmeAddr != msg.NvmeAddr {
|
|
t.Fatalf("round-trip NvmeAddr: got %q, want %q", back.NvmeAddr, msg.NvmeAddr)
|
|
}
|
|
if back.NQN != msg.NQN {
|
|
t.Fatalf("round-trip NQN: got %q, want %q", back.NQN, msg.NQN)
|
|
}
|
|
}
|
|
|
|
// TestQA_NVMe_HeartbeatProto_EmptyFields verifies empty NVMe fields survive
|
|
// round-trip without becoming non-empty.
|
|
func TestQA_NVMe_HeartbeatProto_EmptyFields(t *testing.T) {
|
|
msg := blockvol.BlockVolumeInfoMessage{
|
|
Path: "/data/vol.blk",
|
|
Epoch: 1,
|
|
Role: 1,
|
|
// NvmeAddr and NQN empty.
|
|
}
|
|
|
|
proto := blockvol.InfoMessageToProto(msg)
|
|
if proto.NvmeAddr != "" {
|
|
t.Fatalf("proto NvmeAddr should be empty, got %q", proto.NvmeAddr)
|
|
}
|
|
if proto.Nqn != "" {
|
|
t.Fatalf("proto Nqn should be empty, got %q", proto.Nqn)
|
|
}
|
|
|
|
back := blockvol.InfoMessageFromProto(proto)
|
|
if back.NvmeAddr != "" || back.NQN != "" {
|
|
t.Fatalf("empty NVMe fields should survive round-trip: NvmeAddr=%q NQN=%q", back.NvmeAddr, back.NQN)
|
|
}
|
|
}
|
|
|
|
// TestQA_NVMe_FullHeartbeat_MasterRestart verifies the full master-restart
|
|
// reconstruction sequence: volume created with NVMe → master restarts →
|
|
// heartbeat rebuilds registry → NVMe fields available for Lookup.
|
|
func TestQA_NVMe_FullHeartbeat_MasterRestart(t *testing.T) {
|
|
// Simulate master restart: fresh registry.
|
|
r := NewBlockVolumeRegistry()
|
|
|
|
// Volume server sends first full heartbeat after master restart.
|
|
// The heartbeat includes NVMe fields.
|
|
r.UpdateFullHeartbeat("s1:18080", []*master_pb.BlockVolumeInfoMessage{
|
|
{
|
|
Path: "/data/vol1.blk",
|
|
VolumeSize: 1 << 30,
|
|
Epoch: 10,
|
|
Role: 1,
|
|
NvmeAddr: "10.0.0.1:4420",
|
|
Nqn: "nqn.2024-01.com.seaweedfs:vol1",
|
|
},
|
|
}, "")
|
|
|
|
// After heartbeat, volume should be reconstructed with NVMe fields.
|
|
// Currently the registry uses nameFromPath() to find/create entries.
|
|
// If the entry was auto-created from heartbeat, check NVMe fields.
|
|
entries := r.ListByServer("s1:18080")
|
|
if len(entries) == 0 {
|
|
t.Log("NOTE: fresh registry after master restart may not auto-create entries from heartbeat")
|
|
t.Log("This is expected if the design requires explicit Register before heartbeat updates work")
|
|
t.Skip("auto-creation from heartbeat not supported — entries must be pre-registered")
|
|
}
|
|
|
|
// If entries exist, verify NVMe fields.
|
|
for _, e := range entries {
|
|
if e.Path == "/data/vol1.blk" {
|
|
if e.NvmeAddr != "10.0.0.1:4420" {
|
|
t.Errorf("NvmeAddr not reconstructed from heartbeat: got %q", e.NvmeAddr)
|
|
}
|
|
if e.NQN != "nqn.2024-01.com.seaweedfs:vol1" {
|
|
t.Errorf("NQN not reconstructed from heartbeat: got %q", e.NQN)
|
|
}
|
|
return
|
|
}
|
|
}
|
|
t.Error("vol1.blk entry not found after heartbeat reconstruction")
|
|
}
|
|
|
|
// TestQA_NVMe_ListByServerIncludesNvmeFields verifies that ListByServer returns
|
|
// entries with NVMe fields intact (not stripped during aggregation).
|
|
func TestQA_NVMe_ListByServerIncludesNvmeFields(t *testing.T) {
|
|
r := NewBlockVolumeRegistry()
|
|
r.Register(&BlockVolumeEntry{
|
|
Name: "vol-nvme",
|
|
VolumeServer: "s1",
|
|
Path: "/data/vol-nvme.blk",
|
|
NvmeAddr: "10.0.0.1:4420",
|
|
NQN: "nqn:vol-nvme",
|
|
})
|
|
r.Register(&BlockVolumeEntry{
|
|
Name: "vol-iscsi",
|
|
VolumeServer: "s1",
|
|
Path: "/data/vol-iscsi.blk",
|
|
ISCSIAddr: "10.0.0.1:3260",
|
|
})
|
|
|
|
entries := r.ListByServer("s1")
|
|
if len(entries) != 2 {
|
|
t.Fatalf("expected 2 entries, got %d", len(entries))
|
|
}
|
|
|
|
var foundNvme bool
|
|
for _, e := range entries {
|
|
if e.Name == "vol-nvme" {
|
|
foundNvme = true
|
|
if e.NvmeAddr != "10.0.0.1:4420" {
|
|
t.Errorf("NvmeAddr stripped in ListByServer: got %q", e.NvmeAddr)
|
|
}
|
|
if e.NQN != "nqn:vol-nvme" {
|
|
t.Errorf("NQN stripped in ListByServer: got %q", e.NQN)
|
|
}
|
|
}
|
|
}
|
|
if !foundNvme {
|
|
t.Error("vol-nvme not found in ListByServer results")
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// Integration Tests: NVMe Publication End-to-End Flows
|
|
//
|
|
// These tests exercise the full control-plane path that the user described:
|
|
// Create → Allocate returns NVMe fields → Registry stores them →
|
|
// Heartbeat refreshes them → Lookup/CSI returns them → Failover preserves them.
|
|
// Uses integrationMaster() mock (no real gRPC/NVMe).
|
|
// =============================================================================
|
|
|
|
// nvmeIntegrationMaster creates an integrationMaster with NVMe-capable
|
|
// allocate callback that returns NvmeAddr and NQN.
|
|
func nvmeIntegrationMaster(t *testing.T) *MasterServer {
|
|
t.Helper()
|
|
ms := &MasterServer{
|
|
blockRegistry: NewBlockVolumeRegistry(),
|
|
blockAssignmentQueue: NewBlockAssignmentQueue(),
|
|
blockFailover: newBlockFailoverState(),
|
|
}
|
|
ms.blockVSAllocate = func(ctx context.Context, server string, name string, sizeBytes uint64, diskType string, durabilityMode string) (*blockAllocResult, error) {
|
|
// Simulate volume servers with NVMe enabled.
|
|
// Each server has NVMe on :4420 and a deterministic NQN.
|
|
host := server[:strings.Index(server, ":")]
|
|
return &blockAllocResult{
|
|
Path: fmt.Sprintf("/data/%s.blk", name),
|
|
IQN: fmt.Sprintf("iqn.2024.test:%s", name),
|
|
ISCSIAddr: server[:strings.Index(server, ":")] + ":3260",
|
|
NvmeAddr: host + ":4420",
|
|
NQN: fmt.Sprintf("nqn.2024-01.com.seaweedfs:vol.%s", name),
|
|
ReplicaDataAddr: server[:strings.Index(server, ":")] + ":14260",
|
|
ReplicaCtrlAddr: server[:strings.Index(server, ":")] + ":14261",
|
|
RebuildListenAddr: server[:strings.Index(server, ":")] + ":15000",
|
|
}, nil
|
|
}
|
|
ms.blockVSDelete = func(ctx context.Context, server string, name string) error {
|
|
return nil
|
|
}
|
|
ms.blockRegistry.MarkBlockCapable("10.0.0.1:9333")
|
|
ms.blockRegistry.MarkBlockCapable("10.0.0.2:9333")
|
|
ms.blockRegistry.MarkBlockCapable("10.0.0.3:9333")
|
|
return ms
|
|
}
|
|
|
|
// TestIntegration_NVMe_CreateReturnsNvmeAddr tests the Kubernetes PVC flow:
|
|
// CreateBlockVolume → master picks a server → returns NvmeAddr + NQN for CSI.
|
|
func TestIntegration_NVMe_CreateReturnsNvmeAddr(t *testing.T) {
|
|
ms := nvmeIntegrationMaster(t)
|
|
ctx := context.Background()
|
|
|
|
resp, err := ms.CreateBlockVolume(ctx, &master_pb.CreateBlockVolumeRequest{
|
|
Name: "pvc-abc",
|
|
SizeBytes: 100 << 30, // 100GB
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("CreateBlockVolume: %v", err)
|
|
}
|
|
|
|
// Primary should have NVMe fields.
|
|
if resp.NvmeAddr == "" {
|
|
t.Fatal("CreateBlockVolume response missing NvmeAddr — CSI can't use NVMe/TCP")
|
|
}
|
|
if resp.Nqn == "" {
|
|
t.Fatal("CreateBlockVolume response missing NQN — CSI can't use NVMe/TCP")
|
|
}
|
|
if !strings.Contains(resp.Nqn, "pvc-abc") {
|
|
t.Fatalf("NQN should contain volume name, got %q", resp.Nqn)
|
|
}
|
|
|
|
// NVMe address should match the primary volume server's host.
|
|
primaryHost := resp.VolumeServer[:strings.Index(resp.VolumeServer, ":")]
|
|
expectedNvmeAddr := primaryHost + ":4420"
|
|
if resp.NvmeAddr != expectedNvmeAddr {
|
|
t.Fatalf("NvmeAddr = %q, want %q (primary's NVMe port)", resp.NvmeAddr, expectedNvmeAddr)
|
|
}
|
|
|
|
t.Logf("PVC created: server=%s nvme=%s nqn=%s", resp.VolumeServer, resp.NvmeAddr, resp.Nqn)
|
|
}
|
|
|
|
// TestIntegration_NVMe_LookupReturnsNvmeAddr tests CSI ControllerPublishVolume:
|
|
// Lookup returns NvmeAddr + NQN so the node plugin can `nvme connect`.
|
|
func TestIntegration_NVMe_LookupReturnsNvmeAddr(t *testing.T) {
|
|
ms := nvmeIntegrationMaster(t)
|
|
ctx := context.Background()
|
|
|
|
createResp, err := ms.CreateBlockVolume(ctx, &master_pb.CreateBlockVolumeRequest{
|
|
Name: "pvc-lookup-1",
|
|
SizeBytes: 50 << 30,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Create: %v", err)
|
|
}
|
|
|
|
// CSI calls Lookup to get connection details.
|
|
lookupResp, err := ms.LookupBlockVolume(ctx, &master_pb.LookupBlockVolumeRequest{Name: "pvc-lookup-1"})
|
|
if err != nil {
|
|
t.Fatalf("Lookup: %v", err)
|
|
}
|
|
|
|
// NVMe fields must match what was returned at creation.
|
|
if lookupResp.NvmeAddr != createResp.NvmeAddr {
|
|
t.Fatalf("Lookup NvmeAddr = %q, Create returned %q", lookupResp.NvmeAddr, createResp.NvmeAddr)
|
|
}
|
|
if lookupResp.Nqn != createResp.Nqn {
|
|
t.Fatalf("Lookup NQN = %q, Create returned %q", lookupResp.Nqn, createResp.Nqn)
|
|
}
|
|
|
|
// iSCSI fields should also be available (fallback path).
|
|
if lookupResp.IscsiAddr == "" {
|
|
t.Fatal("Lookup should also return iSCSI addr for fallback")
|
|
}
|
|
if lookupResp.Iqn == "" {
|
|
t.Fatal("Lookup should also return IQN for fallback")
|
|
}
|
|
|
|
t.Logf("CSI Lookup: nvme=%s nqn=%s iscsi=%s iqn=%s",
|
|
lookupResp.NvmeAddr, lookupResp.Nqn, lookupResp.IscsiAddr, lookupResp.Iqn)
|
|
}
|
|
|
|
// TestIntegration_NVMe_FailoverUpdatesNvmeAddr tests that after failover,
|
|
// Lookup returns the NEW primary's NVMe address (not the dead server's).
|
|
func TestIntegration_NVMe_FailoverUpdatesNvmeAddr(t *testing.T) {
|
|
ms := nvmeIntegrationMaster(t)
|
|
ctx := context.Background()
|
|
|
|
createResp, err := ms.CreateBlockVolume(ctx, &master_pb.CreateBlockVolumeRequest{
|
|
Name: "pvc-failover-nvme",
|
|
SizeBytes: 10 << 30,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Create: %v", err)
|
|
}
|
|
primaryVS := createResp.VolumeServer
|
|
primaryHost := primaryVS[:strings.Index(primaryVS, ":")]
|
|
originalNvmeAddr := createResp.NvmeAddr
|
|
|
|
// Expire lease for immediate failover.
|
|
ms.blockRegistry.UpdateEntry("pvc-failover-nvme", func(entry *BlockVolumeEntry) {
|
|
entry.LastLeaseGrant = time.Now().Add(-1 * time.Minute)
|
|
})
|
|
|
|
// Primary dies → replica promoted.
|
|
ms.failoverBlockVolumes(primaryVS)
|
|
|
|
// Verify new primary is different.
|
|
entry, _ := ms.blockRegistry.Lookup("pvc-failover-nvme")
|
|
if entry.VolumeServer == primaryVS {
|
|
t.Fatal("failover didn't promote replica")
|
|
}
|
|
newPrimaryHost := entry.VolumeServer[:strings.Index(entry.VolumeServer, ":")]
|
|
|
|
// Simulate the new primary's heartbeat arriving with its NVMe fields.
|
|
// In production, the VS heartbeat collector sends this automatically.
|
|
ms.blockRegistry.UpdateFullHeartbeat(entry.VolumeServer, []*master_pb.BlockVolumeInfoMessage{
|
|
{
|
|
Path: entry.Path,
|
|
VolumeSize: 10 << 30,
|
|
Epoch: entry.Epoch,
|
|
Role: 1,
|
|
NvmeAddr: newPrimaryHost + ":4420",
|
|
Nqn: fmt.Sprintf("nqn.2024-01.com.seaweedfs:vol.pvc-failover-nvme"),
|
|
},
|
|
}, "")
|
|
|
|
// CSI re-publishes after failover: Lookup must return new NVMe address.
|
|
lookupResp, err := ms.LookupBlockVolume(ctx, &master_pb.LookupBlockVolumeRequest{Name: "pvc-failover-nvme"})
|
|
if err != nil {
|
|
t.Fatalf("post-failover Lookup: %v", err)
|
|
}
|
|
|
|
if lookupResp.NvmeAddr == originalNvmeAddr {
|
|
t.Fatalf("post-failover NvmeAddr still points to dead primary %q", originalNvmeAddr)
|
|
}
|
|
expectedNewAddr := newPrimaryHost + ":4420"
|
|
if lookupResp.NvmeAddr != expectedNewAddr {
|
|
t.Fatalf("post-failover NvmeAddr = %q, want %q", lookupResp.NvmeAddr, expectedNewAddr)
|
|
}
|
|
|
|
t.Logf("Failover: old=%s:%s → new=%s:%s",
|
|
primaryHost, originalNvmeAddr, newPrimaryHost, lookupResp.NvmeAddr)
|
|
}
|
|
|
|
// TestIntegration_NVMe_HeartbeatReconstructionAfterMasterRestart tests the
|
|
// master restart scenario:
|
|
// 1. Fresh registry (master just started)
|
|
// 2. Volume server sends heartbeat with NVMe fields
|
|
// 3. Registry auto-creates entry with NVMe fields
|
|
// 4. CSI Lookup returns NVMe connection details
|
|
func TestIntegration_NVMe_HeartbeatReconstructionAfterMasterRestart(t *testing.T) {
|
|
ms := nvmeIntegrationMaster(t)
|
|
ctx := context.Background()
|
|
|
|
// Step 1: Create volume normally.
|
|
createResp, err := ms.CreateBlockVolume(ctx, &master_pb.CreateBlockVolumeRequest{
|
|
Name: "pvc-restart-1",
|
|
SizeBytes: 20 << 30,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Create: %v", err)
|
|
}
|
|
primaryVS := createResp.VolumeServer
|
|
primaryHost := primaryVS[:strings.Index(primaryVS, ":")]
|
|
|
|
// Step 2: Simulate master restart — fresh registry.
|
|
ms.blockRegistry = NewBlockVolumeRegistry()
|
|
ms.blockRegistry.MarkBlockCapable(primaryVS)
|
|
|
|
// Step 3: Volume server sends heartbeat with NVMe info.
|
|
ms.blockRegistry.UpdateFullHeartbeat(primaryVS, []*master_pb.BlockVolumeInfoMessage{
|
|
{
|
|
Path: "/data/pvc-restart-1.blk",
|
|
VolumeSize: 20 << 30,
|
|
Epoch: 1,
|
|
Role: 1,
|
|
NvmeAddr: primaryHost + ":4420",
|
|
Nqn: "nqn.2024-01.com.seaweedfs:vol.pvc-restart-1",
|
|
},
|
|
}, "")
|
|
|
|
// Step 4: CSI calls Lookup — must find NVMe details.
|
|
lookupResp, err := ms.LookupBlockVolume(ctx, &master_pb.LookupBlockVolumeRequest{Name: "pvc-restart-1"})
|
|
if err != nil {
|
|
t.Fatalf("Lookup after master restart: %v", err)
|
|
}
|
|
|
|
if lookupResp.NvmeAddr != primaryHost+":4420" {
|
|
t.Fatalf("NvmeAddr not reconstructed after master restart: got %q", lookupResp.NvmeAddr)
|
|
}
|
|
if lookupResp.Nqn != "nqn.2024-01.com.seaweedfs:vol.pvc-restart-1" {
|
|
t.Fatalf("NQN not reconstructed after master restart: got %q", lookupResp.Nqn)
|
|
}
|
|
|
|
t.Logf("Post-restart Lookup: nvme=%s nqn=%s", lookupResp.NvmeAddr, lookupResp.Nqn)
|
|
}
|
|
|
|
// TestIntegration_NVMe_MixedCluster tests a cluster where some volume servers
|
|
// have NVMe enabled and others don't. CSI should get NVMe when available,
|
|
// fall back to iSCSI otherwise.
|
|
func TestIntegration_NVMe_MixedCluster(t *testing.T) {
|
|
ms := &MasterServer{
|
|
blockRegistry: NewBlockVolumeRegistry(),
|
|
blockAssignmentQueue: NewBlockAssignmentQueue(),
|
|
blockFailover: newBlockFailoverState(),
|
|
}
|
|
callCount := 0
|
|
ms.blockVSAllocate = func(ctx context.Context, server string, name string, sizeBytes uint64, diskType string, durabilityMode string) (*blockAllocResult, error) {
|
|
callCount++
|
|
host := server[:strings.Index(server, ":")]
|
|
result := &blockAllocResult{
|
|
Path: fmt.Sprintf("/data/%s.blk", name),
|
|
IQN: fmt.Sprintf("iqn.2024.test:%s", name),
|
|
ISCSIAddr: host + ":3260",
|
|
ReplicaDataAddr: host + ":14260",
|
|
ReplicaCtrlAddr: host + ":14261",
|
|
RebuildListenAddr: host + ":15000",
|
|
}
|
|
// Only the first server (primary) has NVMe. Replica doesn't.
|
|
if callCount == 1 {
|
|
result.NvmeAddr = host + ":4420"
|
|
result.NQN = fmt.Sprintf("nqn.2024-01.com.seaweedfs:vol.%s", name)
|
|
}
|
|
return result, nil
|
|
}
|
|
ms.blockVSDelete = func(ctx context.Context, server string, name string) error {
|
|
return nil
|
|
}
|
|
ms.blockRegistry.MarkBlockCapable("nvme-vs:9333")
|
|
ms.blockRegistry.MarkBlockCapable("iscsi-vs:9333")
|
|
|
|
ctx := context.Background()
|
|
resp, err := ms.CreateBlockVolume(ctx, &master_pb.CreateBlockVolumeRequest{
|
|
Name: "pvc-mixed",
|
|
SizeBytes: 10 << 30,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Create: %v", err)
|
|
}
|
|
|
|
// Primary was picked by PickServer (fewest volumes), should have NVMe.
|
|
lookupResp, err := ms.LookupBlockVolume(ctx, &master_pb.LookupBlockVolumeRequest{Name: "pvc-mixed"})
|
|
if err != nil {
|
|
t.Fatalf("Lookup: %v", err)
|
|
}
|
|
|
|
// In all cases, iSCSI should be available.
|
|
if lookupResp.IscsiAddr == "" {
|
|
t.Fatal("iSCSI addr must always be present")
|
|
}
|
|
|
|
// NVMe may or may not be present depending on which server was picked.
|
|
if lookupResp.NvmeAddr != "" {
|
|
t.Logf("Primary %s has NVMe: addr=%s nqn=%s", resp.VolumeServer, lookupResp.NvmeAddr, lookupResp.Nqn)
|
|
if lookupResp.Nqn == "" {
|
|
t.Fatal("if NvmeAddr is set, NQN must also be set")
|
|
}
|
|
} else {
|
|
t.Logf("Primary %s is iSCSI-only: iscsi=%s iqn=%s", resp.VolumeServer, lookupResp.IscsiAddr, lookupResp.Iqn)
|
|
}
|
|
}
|
|
|
|
// TestIntegration_NVMe_VolumeServerHeartbeatCollector tests the volume server
|
|
// side: CollectBlockVolumeHeartbeat populates NvmeAddr and NQN when NVMe
|
|
// is enabled on the BlockService.
|
|
func TestIntegration_NVMe_VolumeServerHeartbeatCollector(t *testing.T) {
|
|
dir := t.TempDir()
|
|
blockDir := dir + "/blocks"
|
|
os.MkdirAll(blockDir, 0755)
|
|
|
|
// Start BlockService WITH NVMe config.
|
|
bs := StartBlockService("127.0.0.1:0", blockDir, "iqn.2024.test:",
|
|
"127.0.0.1:3260,1",
|
|
NVMeConfig{
|
|
Enabled: true,
|
|
ListenAddr: "10.0.0.3:4420",
|
|
NQNPrefix: "nqn.2024-01.com.seaweedfs:vol.",
|
|
})
|
|
if bs == nil {
|
|
t.Fatal("StartBlockService returned nil")
|
|
}
|
|
defer bs.Shutdown()
|
|
|
|
// Create a volume.
|
|
_, _, _, err := bs.CreateBlockVol("test-nvme-hb", 4*1024*1024, "ssd", "")
|
|
if err != nil {
|
|
t.Fatalf("CreateBlockVol: %v", err)
|
|
}
|
|
|
|
// Collect heartbeat.
|
|
msgs := bs.CollectBlockVolumeHeartbeat()
|
|
if len(msgs) == 0 {
|
|
t.Fatal("no heartbeat messages collected")
|
|
}
|
|
|
|
var found bool
|
|
for _, msg := range msgs {
|
|
if strings.Contains(msg.Path, "test-nvme-hb") {
|
|
found = true
|
|
if msg.NvmeAddr != "10.0.0.3:4420" {
|
|
t.Fatalf("heartbeat NvmeAddr = %q, want 10.0.0.3:4420", msg.NvmeAddr)
|
|
}
|
|
if !strings.Contains(msg.NQN, "test-nvme-hb") {
|
|
t.Fatalf("heartbeat NQN should contain volume name, got %q", msg.NQN)
|
|
}
|
|
t.Logf("Heartbeat: nvme=%s nqn=%s", msg.NvmeAddr, msg.NQN)
|
|
}
|
|
}
|
|
if !found {
|
|
t.Fatal("test-nvme-hb not found in heartbeat messages")
|
|
}
|
|
}
|
|
|
|
// TestIntegration_NVMe_VolumeServerNoNvme tests that without NVMe config,
|
|
// the heartbeat correctly omits NvmeAddr and NQN.
|
|
func TestIntegration_NVMe_VolumeServerNoNvme(t *testing.T) {
|
|
dir := t.TempDir()
|
|
blockDir := dir + "/blocks"
|
|
os.MkdirAll(blockDir, 0755)
|
|
|
|
// Start BlockService WITHOUT NVMe.
|
|
bs := StartBlockService("127.0.0.1:0", blockDir, "iqn.2024.test:",
|
|
"127.0.0.1:3260,1", NVMeConfig{})
|
|
if bs == nil {
|
|
t.Fatal("StartBlockService returned nil")
|
|
}
|
|
defer bs.Shutdown()
|
|
|
|
bs.CreateBlockVol("test-no-nvme", 4*1024*1024, "", "")
|
|
|
|
msgs := bs.CollectBlockVolumeHeartbeat()
|
|
for _, msg := range msgs {
|
|
if strings.Contains(msg.Path, "test-no-nvme") {
|
|
if msg.NvmeAddr != "" {
|
|
t.Fatalf("NvmeAddr should be empty without NVMe config, got %q", msg.NvmeAddr)
|
|
}
|
|
if msg.NQN != "" {
|
|
t.Fatalf("NQN should be empty without NVMe config, got %q", msg.NQN)
|
|
}
|
|
return
|
|
}
|
|
}
|
|
t.Fatal("test-no-nvme not found in heartbeat")
|
|
}
|
|
|
|
// TestIntegration_NVMe_FullLifecycle_K8s simulates the complete K8s PVC lifecycle:
|
|
// Admin deploys 3 VS with NVMe → Pod requests PVC → CSI creates via master →
|
|
// Pod connects via NVMe/TCP → Primary dies → Failover → CSI re-publishes →
|
|
// Pod reconnects to new NVMe target.
|
|
func TestIntegration_NVMe_FullLifecycle_K8s(t *testing.T) {
|
|
ms := nvmeIntegrationMaster(t)
|
|
ctx := context.Background()
|
|
|
|
// ── Step 1: Admin deployed VS with --block-nvme-addr :4420 ──
|
|
// (Simulated by nvmeIntegrationMaster's allocate callback)
|
|
|
|
// ── Step 2: Pod requests PVC → CSI controller calls master ──
|
|
createResp, err := ms.CreateBlockVolume(ctx, &master_pb.CreateBlockVolumeRequest{
|
|
Name: "pvc-k8s-data",
|
|
SizeBytes: 100 << 30,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("CreateBlockVolume: %v", err)
|
|
}
|
|
primaryVS := createResp.VolumeServer
|
|
replicaVS := createResp.ReplicaServer
|
|
if replicaVS == "" {
|
|
t.Fatal("expected replica for HA")
|
|
}
|
|
|
|
t.Logf("Step 2: Created pvc-k8s-data on primary=%s replica=%s", primaryVS, replicaVS)
|
|
|
|
// ── Step 3: CSI controller passes NVMe details in PublishContext ──
|
|
lookupResp, err := ms.LookupBlockVolume(ctx, &master_pb.LookupBlockVolumeRequest{Name: "pvc-k8s-data"})
|
|
if err != nil {
|
|
t.Fatalf("Lookup: %v", err)
|
|
}
|
|
if lookupResp.NvmeAddr == "" || lookupResp.Nqn == "" {
|
|
t.Fatalf("CSI needs NVMe details: nvmeAddr=%q nqn=%q", lookupResp.NvmeAddr, lookupResp.Nqn)
|
|
}
|
|
|
|
// CSI node plugin would do: nvme connect -t tcp -a <host> -s 4420 -n <nqn>
|
|
publishNvmeAddr := lookupResp.NvmeAddr
|
|
publishNQN := lookupResp.Nqn
|
|
t.Logf("Step 3: CSI publish: nvme=%s nqn=%s", publishNvmeAddr, publishNQN)
|
|
|
|
// ── Step 4: Confirm assignments (VS heartbeats) ──
|
|
entry, _ := ms.blockRegistry.Lookup("pvc-k8s-data")
|
|
ms.blockAssignmentQueue.ConfirmFromHeartbeat(primaryVS, []blockvol.BlockVolumeInfoMessage{
|
|
{Path: entry.Path, Epoch: 1},
|
|
})
|
|
replicaPath := ""
|
|
if len(entry.Replicas) > 0 {
|
|
replicaPath = entry.Replicas[0].Path
|
|
} else {
|
|
replicaPath = entry.ReplicaPath
|
|
}
|
|
ms.blockAssignmentQueue.ConfirmFromHeartbeat(replicaVS, []blockvol.BlockVolumeInfoMessage{
|
|
{Path: replicaPath, Epoch: 1},
|
|
})
|
|
|
|
// ── Step 5: Primary VS dies ──
|
|
ms.blockRegistry.UpdateEntry("pvc-k8s-data", func(e *BlockVolumeEntry) {
|
|
e.LastLeaseGrant = time.Now().Add(-1 * time.Minute)
|
|
})
|
|
ms.failoverBlockVolumes(primaryVS)
|
|
|
|
entry, _ = ms.blockRegistry.Lookup("pvc-k8s-data")
|
|
if entry.VolumeServer == primaryVS {
|
|
t.Fatal("failover didn't promote replica")
|
|
}
|
|
newPrimaryVS := entry.VolumeServer
|
|
newPrimaryHost := newPrimaryVS[:strings.Index(newPrimaryVS, ":")]
|
|
t.Logf("Step 5: Failover: new primary=%s epoch=%d", newPrimaryVS, entry.Epoch)
|
|
|
|
// ── Step 6: New primary's heartbeat arrives with NVMe info ──
|
|
ms.blockRegistry.UpdateFullHeartbeat(newPrimaryVS, []*master_pb.BlockVolumeInfoMessage{
|
|
{
|
|
Path: entry.Path,
|
|
VolumeSize: 100 << 30,
|
|
Epoch: entry.Epoch,
|
|
Role: 1,
|
|
NvmeAddr: newPrimaryHost + ":4420",
|
|
Nqn: "nqn.2024-01.com.seaweedfs:vol.pvc-k8s-data",
|
|
},
|
|
}, "")
|
|
|
|
// ── Step 7: CSI re-publishes → node plugin reconnects via NVMe ──
|
|
lookupResp, err = ms.LookupBlockVolume(ctx, &master_pb.LookupBlockVolumeRequest{Name: "pvc-k8s-data"})
|
|
if err != nil {
|
|
t.Fatalf("post-failover Lookup: %v", err)
|
|
}
|
|
|
|
// NVMe target must now point to the NEW primary.
|
|
if lookupResp.NvmeAddr == publishNvmeAddr {
|
|
t.Fatalf("NvmeAddr still points to dead primary: %q", lookupResp.NvmeAddr)
|
|
}
|
|
expectedNewNvme := newPrimaryHost + ":4420"
|
|
if lookupResp.NvmeAddr != expectedNewNvme {
|
|
t.Fatalf("NvmeAddr = %q, want %q (new primary)", lookupResp.NvmeAddr, expectedNewNvme)
|
|
}
|
|
if lookupResp.Nqn != publishNQN {
|
|
// NQN is volume-specific, should be same regardless of which server hosts it.
|
|
t.Logf("Note: NQN changed from %q to %q (expected: same across failover)", publishNQN, lookupResp.Nqn)
|
|
}
|
|
|
|
t.Logf("Step 7: CSI re-publish: new nvme=%s nqn=%s", lookupResp.NvmeAddr, lookupResp.Nqn)
|
|
|
|
// ── Step 8: Cleanup — delete volume ──
|
|
_, err = ms.DeleteBlockVolume(ctx, &master_pb.DeleteBlockVolumeRequest{Name: "pvc-k8s-data"})
|
|
if err != nil {
|
|
t.Fatalf("Delete: %v", err)
|
|
}
|
|
if _, ok := ms.blockRegistry.Lookup("pvc-k8s-data"); ok {
|
|
t.Fatal("volume should be deleted")
|
|
}
|
|
t.Log("Step 8: Volume deleted")
|
|
}
|
|
|
|
// =============================================================================
|
|
// C2: NVMe Toggle on Running VS
|
|
//
|
|
// Simulates a volume server enabling NVMe, sending heartbeats with NVMe
|
|
// fields, then disabling NVMe and sending heartbeats without. Verifies
|
|
// that the registry reflects the current state unconditionally.
|
|
// =============================================================================
|
|
|
|
// TestQA_NVMe_ToggleNvmeOnRunningVS tests the primary-side NVMe toggle:
|
|
// iSCSI-only → enable NVMe via heartbeat → disable NVMe via heartbeat.
|
|
func TestQA_NVMe_ToggleNvmeOnRunningVS(t *testing.T) {
|
|
r := NewBlockVolumeRegistry()
|
|
|
|
// Step 1: Register volume with NvmeAddr="" (iSCSI-only initially).
|
|
err := r.Register(&BlockVolumeEntry{
|
|
Name: "toggle-vol",
|
|
VolumeServer: "vs1:18080",
|
|
Path: "/data/toggle-vol.blk",
|
|
IQN: "iqn.2024.com.seaweedfs:toggle-vol",
|
|
ISCSIAddr: "10.0.0.1:3260",
|
|
// NvmeAddr intentionally empty — iSCSI-only at creation.
|
|
SizeBytes: 1 << 30,
|
|
Epoch: 1,
|
|
Role: blockvol.RoleToWire(blockvol.RolePrimary),
|
|
Status: StatusActive,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Register: %v", err)
|
|
}
|
|
|
|
entry, ok := r.Lookup("toggle-vol")
|
|
if !ok {
|
|
t.Fatal("toggle-vol not found after Register")
|
|
}
|
|
if entry.NvmeAddr != "" {
|
|
t.Fatalf("initial NvmeAddr should be empty, got %q", entry.NvmeAddr)
|
|
}
|
|
|
|
// Step 2: Heartbeat arrives with NvmeAddr (admin enabled NVMe on VS).
|
|
r.UpdateFullHeartbeat("vs1:18080", []*master_pb.BlockVolumeInfoMessage{
|
|
{
|
|
Path: "/data/toggle-vol.blk",
|
|
VolumeSize: 1 << 30,
|
|
Epoch: 1,
|
|
Role: 1,
|
|
NvmeAddr: "10.0.0.1:4420",
|
|
Nqn: "nqn.2024-01.com.seaweedfs:toggle-vol",
|
|
},
|
|
}, "")
|
|
|
|
entry, _ = r.Lookup("toggle-vol")
|
|
if entry.NvmeAddr != "10.0.0.1:4420" {
|
|
t.Fatalf("after enable heartbeat: NvmeAddr = %q, want 10.0.0.1:4420", entry.NvmeAddr)
|
|
}
|
|
if entry.NQN != "nqn.2024-01.com.seaweedfs:toggle-vol" {
|
|
t.Fatalf("after enable heartbeat: NQN = %q, want nqn.2024-01.com.seaweedfs:toggle-vol", entry.NQN)
|
|
}
|
|
|
|
// Step 3: Heartbeat arrives with NvmeAddr="" (admin disabled NVMe on VS).
|
|
// UpdateFullHeartbeat unconditionally writes NvmeAddr/NQN, so empty clears.
|
|
r.UpdateFullHeartbeat("vs1:18080", []*master_pb.BlockVolumeInfoMessage{
|
|
{
|
|
Path: "/data/toggle-vol.blk",
|
|
VolumeSize: 1 << 30,
|
|
Epoch: 1,
|
|
Role: 1,
|
|
// NvmeAddr and Nqn intentionally empty — NVMe disabled.
|
|
},
|
|
}, "")
|
|
|
|
entry, _ = r.Lookup("toggle-vol")
|
|
if entry.NvmeAddr != "" {
|
|
t.Fatalf("after disable heartbeat: NvmeAddr should be empty, got %q", entry.NvmeAddr)
|
|
}
|
|
if entry.NQN != "" {
|
|
t.Fatalf("after disable heartbeat: NQN should be empty, got %q", entry.NQN)
|
|
}
|
|
|
|
// Step 4: Lookup returns empty NvmeAddr after disable — CSI falls back to iSCSI.
|
|
entry, ok = r.Lookup("toggle-vol")
|
|
if !ok {
|
|
t.Fatal("toggle-vol disappeared")
|
|
}
|
|
if entry.NvmeAddr != "" {
|
|
t.Fatalf("Lookup after disable: NvmeAddr = %q, want empty", entry.NvmeAddr)
|
|
}
|
|
if entry.ISCSIAddr != "10.0.0.1:3260" {
|
|
t.Fatalf("iSCSI addr should be preserved: got %q", entry.ISCSIAddr)
|
|
}
|
|
}
|
|
|
|
// TestQA_NVMe_ToggleNvmeOnRunningVS_ReplicaSide tests the same toggle behavior
|
|
// on a replica: enable NVMe via replica heartbeat → disable via heartbeat.
|
|
func TestQA_NVMe_ToggleNvmeOnRunningVS_ReplicaSide(t *testing.T) {
|
|
r := NewBlockVolumeRegistry()
|
|
|
|
// Step 1: Register volume with a replica that has no NvmeAddr.
|
|
err := r.Register(&BlockVolumeEntry{
|
|
Name: "toggle-replica-vol",
|
|
VolumeServer: "primary-vs:18080",
|
|
Path: "/data/toggle-replica-vol.blk",
|
|
IQN: "iqn.2024.com.seaweedfs:toggle-replica-vol",
|
|
ISCSIAddr: "10.0.0.1:3260",
|
|
SizeBytes: 1 << 30,
|
|
Epoch: 1,
|
|
Role: blockvol.RoleToWire(blockvol.RolePrimary),
|
|
Status: StatusActive,
|
|
LeaseTTL: 30 * time.Second,
|
|
WALHeadLSN: 100,
|
|
Replicas: []ReplicaInfo{
|
|
{
|
|
Server: "replica-vs:18080",
|
|
Path: "/data/toggle-replica-vol.blk",
|
|
IQN: "iqn.2024.com.seaweedfs:toggle-replica-vol-r",
|
|
ISCSIAddr: "10.0.0.2:3260",
|
|
HealthScore: 1.0,
|
|
WALHeadLSN: 100,
|
|
LastHeartbeat: time.Now(),
|
|
Role: blockvol.RoleToWire(blockvol.RoleReplica),
|
|
// NvmeAddr intentionally empty — replica has no NVMe initially.
|
|
},
|
|
},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Register: %v", err)
|
|
}
|
|
r.mu.Lock()
|
|
r.addToServer("replica-vs:18080", "toggle-replica-vol")
|
|
r.mu.Unlock()
|
|
|
|
// Verify replica has no NvmeAddr initially.
|
|
entry, _ := r.Lookup("toggle-replica-vol")
|
|
if len(entry.Replicas) == 0 {
|
|
t.Fatal("expected at least one replica")
|
|
}
|
|
if entry.Replicas[0].NvmeAddr != "" {
|
|
t.Fatalf("initial replica NvmeAddr should be empty, got %q", entry.Replicas[0].NvmeAddr)
|
|
}
|
|
|
|
// Step 2: Replica heartbeat arrives with NvmeAddr (NVMe enabled on replica VS).
|
|
r.UpdateFullHeartbeat("replica-vs:18080", []*master_pb.BlockVolumeInfoMessage{
|
|
{
|
|
Path: "/data/toggle-replica-vol.blk",
|
|
VolumeSize: 1 << 30,
|
|
Epoch: 1,
|
|
Role: uint32(blockvol.RoleToWire(blockvol.RoleReplica)),
|
|
HealthScore: 1.0,
|
|
WalHeadLsn: 100,
|
|
NvmeAddr: "10.0.0.2:4420",
|
|
Nqn: "nqn.2024-01.com.seaweedfs:toggle-replica-vol",
|
|
},
|
|
}, "")
|
|
|
|
entry, _ = r.Lookup("toggle-replica-vol")
|
|
if entry.Replicas[0].NvmeAddr != "10.0.0.2:4420" {
|
|
t.Fatalf("after enable heartbeat: replica NvmeAddr = %q, want 10.0.0.2:4420", entry.Replicas[0].NvmeAddr)
|
|
}
|
|
if entry.Replicas[0].NQN != "nqn.2024-01.com.seaweedfs:toggle-replica-vol" {
|
|
t.Fatalf("after enable heartbeat: replica NQN = %q", entry.Replicas[0].NQN)
|
|
}
|
|
|
|
// Step 3: Replica heartbeat arrives without NvmeAddr (NVMe disabled on replica VS).
|
|
r.UpdateFullHeartbeat("replica-vs:18080", []*master_pb.BlockVolumeInfoMessage{
|
|
{
|
|
Path: "/data/toggle-replica-vol.blk",
|
|
VolumeSize: 1 << 30,
|
|
Epoch: 1,
|
|
Role: uint32(blockvol.RoleToWire(blockvol.RoleReplica)),
|
|
HealthScore: 1.0,
|
|
WalHeadLsn: 100,
|
|
// NvmeAddr and Nqn intentionally empty — NVMe disabled.
|
|
},
|
|
}, "")
|
|
|
|
entry, _ = r.Lookup("toggle-replica-vol")
|
|
if entry.Replicas[0].NvmeAddr != "" {
|
|
t.Fatalf("after disable heartbeat: replica NvmeAddr should be empty, got %q", entry.Replicas[0].NvmeAddr)
|
|
}
|
|
if entry.Replicas[0].NQN != "" {
|
|
t.Fatalf("after disable heartbeat: replica NQN should be empty, got %q", entry.Replicas[0].NQN)
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// C3: Promotion → Immediate Lookup (race window)
|
|
//
|
|
// After PromoteBestReplica, the promoted replica's NVMe fields from its
|
|
// ReplicaInfo are copied into the entry. This tests three sub-cases:
|
|
// (a) Replica had NvmeAddr → Lookup gets it immediately
|
|
// (b) Replica had empty NvmeAddr → Lookup returns empty (CSI falls back)
|
|
// (c) Heartbeat after promotion fills in NvmeAddr
|
|
// =============================================================================
|
|
|
|
func TestQA_NVMe_PromotionThenImmediateLookup(t *testing.T) {
|
|
// Sub-case (a): Replica heartbeated NvmeAddr into ReplicaInfo → promote →
|
|
// Lookup returns NvmeAddr immediately (no extra heartbeat needed).
|
|
t.Run("ReplicaHasNvme", func(t *testing.T) {
|
|
r := NewBlockVolumeRegistry()
|
|
// Mark servers as block-capable so promotion Gate 4 (liveness) passes.
|
|
r.MarkBlockCapable("dead-primary:18080")
|
|
r.MarkBlockCapable("healthy-replica:18080")
|
|
err := r.Register(&BlockVolumeEntry{
|
|
Name: "promo-nvme-vol",
|
|
VolumeServer: "dead-primary:18080",
|
|
Path: "/data/promo-nvme-vol.blk",
|
|
IQN: "iqn:promo-primary",
|
|
ISCSIAddr: "10.0.0.1:3260",
|
|
NvmeAddr: "10.0.0.1:4420",
|
|
NQN: "nqn:promo-primary",
|
|
SizeBytes: 1 << 30,
|
|
Epoch: 5,
|
|
Role: blockvol.RoleToWire(blockvol.RolePrimary),
|
|
Status: StatusActive,
|
|
LeaseTTL: 30 * time.Second,
|
|
WALHeadLSN: 200,
|
|
Replicas: []ReplicaInfo{
|
|
{
|
|
Server: "healthy-replica:18080",
|
|
Path: "/data/promo-nvme-vol.blk",
|
|
IQN: "iqn:promo-replica",
|
|
ISCSIAddr: "10.0.0.2:3260",
|
|
NvmeAddr: "10.0.0.2:4420", // Replica has NVMe!
|
|
NQN: "nqn:promo-replica",
|
|
HealthScore: 1.0,
|
|
WALHeadLSN: 200,
|
|
LastHeartbeat: time.Now(),
|
|
Role: blockvol.RoleToWire(blockvol.RoleReplica),
|
|
},
|
|
},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Register: %v", err)
|
|
}
|
|
r.mu.Lock()
|
|
r.addToServer("healthy-replica:18080", "promo-nvme-vol")
|
|
r.mu.Unlock()
|
|
|
|
newEpoch, err := r.PromoteBestReplica("promo-nvme-vol")
|
|
if err != nil {
|
|
t.Fatalf("PromoteBestReplica: %v", err)
|
|
}
|
|
if newEpoch != 6 {
|
|
t.Fatalf("newEpoch = %d, want 6", newEpoch)
|
|
}
|
|
|
|
// Immediate Lookup — no heartbeat needed.
|
|
entry, ok := r.Lookup("promo-nvme-vol")
|
|
if !ok {
|
|
t.Fatal("promo-nvme-vol not found after promotion")
|
|
}
|
|
if entry.VolumeServer != "healthy-replica:18080" {
|
|
t.Fatalf("VolumeServer = %q, want healthy-replica:18080", entry.VolumeServer)
|
|
}
|
|
// CORRECT behavior: NvmeAddr is available immediately from ReplicaInfo.
|
|
if entry.NvmeAddr != "10.0.0.2:4420" {
|
|
t.Fatalf("NvmeAddr = %q, want 10.0.0.2:4420 (should be copied from replica)", entry.NvmeAddr)
|
|
}
|
|
if entry.NQN != "nqn:promo-replica" {
|
|
t.Fatalf("NQN = %q, want nqn:promo-replica (should be copied from replica)", entry.NQN)
|
|
}
|
|
})
|
|
|
|
// Sub-case (b): Replica ReplicaInfo has empty NvmeAddr (heartbeat not yet
|
|
// received or old replica) → promote → Lookup returns empty NvmeAddr →
|
|
// CSI falls back to iSCSI. This documents the pre-heartbeat window.
|
|
t.Run("ReplicaMissingNvme", func(t *testing.T) {
|
|
r := NewBlockVolumeRegistry()
|
|
// Mark servers as block-capable so promotion Gate 4 (liveness) passes.
|
|
r.MarkBlockCapable("dead-primary:18080")
|
|
r.MarkBlockCapable("replica-no-nvme:18080")
|
|
err := r.Register(&BlockVolumeEntry{
|
|
Name: "promo-nonvme-vol",
|
|
VolumeServer: "dead-primary:18080",
|
|
Path: "/data/promo-nonvme-vol.blk",
|
|
IQN: "iqn:promo2-primary",
|
|
ISCSIAddr: "10.0.0.1:3260",
|
|
NvmeAddr: "10.0.0.1:4420",
|
|
NQN: "nqn:promo2-primary",
|
|
SizeBytes: 1 << 30,
|
|
Epoch: 5,
|
|
Role: blockvol.RoleToWire(blockvol.RolePrimary),
|
|
Status: StatusActive,
|
|
LeaseTTL: 30 * time.Second,
|
|
WALHeadLSN: 200,
|
|
Replicas: []ReplicaInfo{
|
|
{
|
|
Server: "replica-no-nvme:18080",
|
|
Path: "/data/promo-nonvme-vol.blk",
|
|
IQN: "iqn:promo2-replica",
|
|
ISCSIAddr: "10.0.0.3:3260",
|
|
// NvmeAddr intentionally empty — replica hasn't heartbeated NVMe.
|
|
HealthScore: 1.0,
|
|
WALHeadLSN: 200,
|
|
LastHeartbeat: time.Now(),
|
|
Role: blockvol.RoleToWire(blockvol.RoleReplica),
|
|
},
|
|
},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Register: %v", err)
|
|
}
|
|
r.mu.Lock()
|
|
r.addToServer("replica-no-nvme:18080", "promo-nonvme-vol")
|
|
r.mu.Unlock()
|
|
|
|
_, err = r.PromoteBestReplica("promo-nonvme-vol")
|
|
if err != nil {
|
|
t.Fatalf("PromoteBestReplica: %v", err)
|
|
}
|
|
|
|
// Immediate Lookup — NvmeAddr should be empty (replica had none).
|
|
entry, ok := r.Lookup("promo-nonvme-vol")
|
|
if !ok {
|
|
t.Fatal("promo-nonvme-vol not found after promotion")
|
|
}
|
|
if entry.VolumeServer != "replica-no-nvme:18080" {
|
|
t.Fatalf("VolumeServer = %q, want replica-no-nvme:18080", entry.VolumeServer)
|
|
}
|
|
// Pre-heartbeat window: NvmeAddr is empty. CSI must fall back to iSCSI.
|
|
if entry.NvmeAddr != "" {
|
|
t.Fatalf("NvmeAddr = %q, want empty (replica had no NVMe info)", entry.NvmeAddr)
|
|
}
|
|
if entry.NQN != "" {
|
|
t.Fatalf("NQN = %q, want empty (replica had no NVMe info)", entry.NQN)
|
|
}
|
|
// iSCSI should still be available for fallback.
|
|
if entry.ISCSIAddr != "10.0.0.3:3260" {
|
|
t.Fatalf("ISCSIAddr = %q, want 10.0.0.3:3260 (iSCSI fallback)", entry.ISCSIAddr)
|
|
}
|
|
})
|
|
|
|
// Sub-case (c): Same as (b) but then heartbeat arrives from the promoted
|
|
// server with NvmeAddr → entry updated → Lookup returns it.
|
|
// This proves heartbeat fixes the post-promotion race window.
|
|
t.Run("HeartbeatFixesPostPromotion", func(t *testing.T) {
|
|
r := NewBlockVolumeRegistry()
|
|
// Mark servers as block-capable so promotion Gate 4 (liveness) passes.
|
|
r.MarkBlockCapable("dead-primary:18080")
|
|
r.MarkBlockCapable("promoted-replica:18080")
|
|
err := r.Register(&BlockVolumeEntry{
|
|
Name: "promo-fix-vol",
|
|
VolumeServer: "dead-primary:18080",
|
|
Path: "/data/promo-fix-vol.blk",
|
|
IQN: "iqn:promo3-primary",
|
|
ISCSIAddr: "10.0.0.1:3260",
|
|
NvmeAddr: "10.0.0.1:4420",
|
|
NQN: "nqn:promo3-primary",
|
|
SizeBytes: 1 << 30,
|
|
Epoch: 5,
|
|
Role: blockvol.RoleToWire(blockvol.RolePrimary),
|
|
Status: StatusActive,
|
|
LeaseTTL: 30 * time.Second,
|
|
WALHeadLSN: 200,
|
|
Replicas: []ReplicaInfo{
|
|
{
|
|
Server: "promoted-replica:18080",
|
|
Path: "/data/promo-fix-vol.blk",
|
|
IQN: "iqn:promo3-replica",
|
|
ISCSIAddr: "10.0.0.4:3260",
|
|
// NvmeAddr intentionally empty — pre-heartbeat window.
|
|
HealthScore: 1.0,
|
|
WALHeadLSN: 200,
|
|
LastHeartbeat: time.Now(),
|
|
Role: blockvol.RoleToWire(blockvol.RoleReplica),
|
|
},
|
|
},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Register: %v", err)
|
|
}
|
|
r.mu.Lock()
|
|
r.addToServer("promoted-replica:18080", "promo-fix-vol")
|
|
r.mu.Unlock()
|
|
|
|
newEpoch, err := r.PromoteBestReplica("promo-fix-vol")
|
|
if err != nil {
|
|
t.Fatalf("PromoteBestReplica: %v", err)
|
|
}
|
|
|
|
// Verify NvmeAddr is empty immediately after promotion.
|
|
entry, _ := r.Lookup("promo-fix-vol")
|
|
if entry.NvmeAddr != "" {
|
|
t.Fatalf("NvmeAddr should be empty immediately after promotion, got %q", entry.NvmeAddr)
|
|
}
|
|
|
|
// Heartbeat arrives from the promoted server WITH NvmeAddr.
|
|
// This is the fix: the new primary's heartbeat fills in NVMe fields.
|
|
r.UpdateFullHeartbeat("promoted-replica:18080", []*master_pb.BlockVolumeInfoMessage{
|
|
{
|
|
Path: "/data/promo-fix-vol.blk",
|
|
VolumeSize: 1 << 30,
|
|
Epoch: newEpoch,
|
|
Role: 1,
|
|
NvmeAddr: "10.0.0.4:4420",
|
|
Nqn: "nqn.2024-01.com.seaweedfs:promo-fix-vol",
|
|
},
|
|
}, "")
|
|
|
|
// Now Lookup should return the NvmeAddr.
|
|
entry, ok := r.Lookup("promo-fix-vol")
|
|
if !ok {
|
|
t.Fatal("promo-fix-vol not found after heartbeat")
|
|
}
|
|
if entry.NvmeAddr != "10.0.0.4:4420" {
|
|
t.Fatalf("NvmeAddr = %q after heartbeat fix, want 10.0.0.4:4420", entry.NvmeAddr)
|
|
}
|
|
if entry.NQN != "nqn.2024-01.com.seaweedfs:promo-fix-vol" {
|
|
t.Fatalf("NQN = %q after heartbeat fix, want nqn.2024-01.com.seaweedfs:promo-fix-vol", entry.NQN)
|
|
}
|
|
// Verify the volume server is the promoted replica.
|
|
if entry.VolumeServer != "promoted-replica:18080" {
|
|
t.Fatalf("VolumeServer = %q, want promoted-replica:18080", entry.VolumeServer)
|
|
}
|
|
})
|
|
}
|