Files
seaweedfs/weed/storage/blockvol/test/ha_test.go
T
Ping QiuandClaude Opus 4.6 98d0e9e631 feat: Phase 5 CP5-1 -- ALUA + multipath failover, 28 tests
Add ALUA (Asymmetric Logical Unit Access) support to the iSCSI target,
enabling dm-multipath on Linux to automatically detect path state changes
and reroute I/O during HA failover without initiator-side intervention.

- ALUAProvider interface with implicit ALUA (TPGS=0x01)
- INQUIRY byte 5 TPGS bits, VPD 0x83 with NAA+TPG+RTP descriptors
- REPORT TARGET PORT GROUPS handler (MAINTENANCE IN SA=0x0A)
- MAINTENANCE OUT rejection (implicit-only, no SET TPG)
- Standby write rejection (NOT_READY ASC=04h ASCQ=0Bh)
- RoleNone maps to Active/Optimized (standalone single-node compatibility)
- NAA-6 device identifier derived from volume UUID
- -tpg-id flag with [1,65535] validation
- dm-multipath config + setup script (group_by_tpg, ALUA prio)
- 12 unit tests + 16 QA adversarial tests + 4 integration tests

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 23:29:58 -08:00

1196 lines
38 KiB
Go

//go:build integration
package test
import (
"context"
"fmt"
"strings"
"testing"
"time"
)
// Role wire values (must match blockvol.Role constants).
const (
rolePrimary = 1
roleReplica = 2
roleStale = 3
roleRebuilding = 4
)
// Port assignments for HA tests. Tests are serial so no conflicts.
const (
haISCSIPort1 = 3260 // primary iSCSI
haISCSIPort2 = 3261 // replica iSCSI (used after promotion)
haAdminPort1 = 8080 // primary admin
haAdminPort2 = 8081 // replica admin
haReplData1 = 9011 // replica receiver data (on replica node)
haReplCtrl1 = 9012 // replica receiver ctrl (on replica node)
haRebuildPort1 = 9013 // rebuild server (primary)
haRebuildPort2 = 9014 // rebuild server (replica, after promotion)
)
// newHAPair creates a primary HATarget on targetNode and a replica HATarget
// on clientNode (or same node in WSL2 mode with different ports).
// The primary has no replica receiver; the replica has replica-data/ctrl listeners.
func newHAPair(t *testing.T, volSize string) (primary, replica *HATarget, iscsiClient *ISCSIClient) {
t.Helper()
// Kill leftover HA processes and iSCSI sessions from previous tests
cleanCtx, cleanCancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cleanCancel()
clientNode.RunRoot(cleanCtx, "iscsiadm -m node --logoutall=all 2>/dev/null")
targetNode.Run(cleanCtx, "pkill -9 -f blockvol-ha 2>/dev/null")
if clientNode != targetNode {
clientNode.Run(cleanCtx, "pkill -9 -f blockvol-ha 2>/dev/null")
}
time.Sleep(2 * time.Second) // let ports release from TIME_WAIT
name := strings.ReplaceAll(t.Name(), "/", "-")
// Primary target on targetNode
primaryCfg := DefaultTargetConfig()
primaryCfg.IQN = iqnPrefix + "-" + strings.ToLower(name) + "-pri"
primaryCfg.Port = haISCSIPort1
if volSize != "" {
primaryCfg.VolSize = volSize
}
// Don't start rebuild server at startup; tests start it on-demand via admin API.
primary = NewHATarget(targetNode, primaryCfg, haAdminPort1, 0, 0, 0)
primary.volFile = "/tmp/blockvol-ha-primary.blk"
primary.logFile = "/tmp/iscsi-ha-primary.log"
// Replica target on clientNode (or same node with different ports in WSL2)
replicaCfg := DefaultTargetConfig()
replicaCfg.IQN = iqnPrefix + "-" + strings.ToLower(name) + "-rep"
replicaCfg.Port = haISCSIPort2
if volSize != "" {
replicaCfg.VolSize = volSize
}
replica = NewHATarget(clientNode, replicaCfg, haAdminPort2, haReplData1, haReplCtrl1, 0)
replica.volFile = "/tmp/blockvol-ha-replica.blk"
replica.logFile = "/tmp/iscsi-ha-replica.log"
// Deploy binary to client node if it differs from target node
if clientNode != targetNode {
if err := replica.Deploy(*flagRepoDir + "/iscsi-target-linux"); err != nil {
t.Fatalf("deploy replica binary: %v", err)
}
}
iscsiClient = NewISCSIClient(clientNode)
t.Cleanup(func() {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
iscsiClient.Logout(ctx, primaryCfg.IQN)
iscsiClient.Logout(ctx, replicaCfg.IQN)
primary.Stop(ctx)
replica.Stop(ctx)
primary.Cleanup(ctx)
replica.Cleanup(ctx)
})
t.Cleanup(func() {
artifacts.CollectLabeled(t, primary.Target, "primary")
artifacts.CollectLabeled(t, replica.Target, "replica")
})
return primary, replica, iscsiClient
}
// replicaAddr returns the address the primary should ship WAL to (replica node).
func replicaAddr(port int) string {
host := *flagClientHost
if *flagEnv == "wsl2" {
host = "127.0.0.1"
}
return fmt.Sprintf("%s:%d", host, port)
}
// primaryAddr returns the address of the primary node (for rebuild, etc).
func primaryAddr(port int) string {
host := *flagTargetHost
if *flagEnv == "wsl2" {
host = "127.0.0.1"
}
return fmt.Sprintf("%s:%d", host, port)
}
// setupPrimaryReplica starts primary+replica, assigns roles, sets up WAL shipping.
func setupPrimaryReplica(t *testing.T, ctx context.Context, primary, replica *HATarget, leaseTTLMs uint32) {
t.Helper()
// Start both targets
t.Log("starting primary...")
if err := primary.Start(ctx, true); err != nil {
t.Fatalf("start primary: %v", err)
}
t.Log("starting replica...")
if err := replica.Start(ctx, true); err != nil {
t.Fatalf("start replica: %v", err)
}
// Assign roles: replica first (so receiver is ready), then primary
t.Log("assigning replica role...")
if err := replica.Assign(ctx, 1, roleReplica, 0); err != nil {
t.Fatalf("assign replica: %v", err)
}
t.Log("assigning primary role...")
if err := primary.Assign(ctx, 1, rolePrimary, leaseTTLMs); err != nil {
t.Fatalf("assign primary: %v", err)
}
// Set WAL shipping: primary ships to replica's data/ctrl ports
t.Log("configuring WAL shipping...")
if err := primary.SetReplica(ctx, replicaAddr(haReplData1), replicaAddr(haReplCtrl1)); err != nil {
t.Fatalf("set replica target: %v", err)
}
}
func TestHA(t *testing.T) {
t.Run("FailoverKillPrimary", testFailoverKillPrimary)
t.Run("FailoverIOContinuity", testFailoverIOContinuity)
t.Run("SplitBrainPrevention", testSplitBrainPrevention)
t.Run("ReplicaRebuild", testReplicaRebuild)
t.Run("EpochStaleReject", testEpochStaleReject)
t.Run("DemoteDrainUnderIO", testDemoteDrainUnderIO)
t.Run("AdminAssign_BadRole", testAdminAssignBadRole)
}
// testFailoverKillPrimary: data written to primary is readable after promoting replica.
func testFailoverKillPrimary(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
defer cancel()
primary, replica, iscsi := newHAPair(t, "100M")
setupPrimaryReplica(t, ctx, primary, replica, 30000)
host := targetHost()
// Client mounts iSCSI to primary
t.Log("discovering + logging in to primary...")
if _, err := iscsi.Discover(ctx, host, haISCSIPort1); err != nil {
t.Fatalf("discover primary: %v", err)
}
dev, err := iscsi.Login(ctx, primary.config.IQN)
if err != nil {
t.Fatalf("login primary: %v", err)
}
t.Logf("primary device: %s", dev)
// Write 1MB pattern, capture md5
t.Log("writing 1MB pattern to primary...")
clientNode.RunRoot(ctx, fmt.Sprintf(
"dd if=/dev/urandom of=/tmp/ha-pattern.bin bs=1M count=1 2>/dev/null"))
wMD5, _, _, _ := clientNode.RunRoot(ctx, "md5sum /tmp/ha-pattern.bin | awk '{print $1}'")
wMD5 = strings.TrimSpace(wMD5)
t.Logf("write md5: %s", wMD5)
stdout, stderr, code, err := clientNode.RunRoot(ctx, fmt.Sprintf(
"dd if=/tmp/ha-pattern.bin of=%s bs=1M count=1 oflag=direct", dev))
if err != nil || code != 0 {
t.Fatalf("dd write to primary: code=%d err=%v\nstdout=%s\nstderr=%s", code, err, stdout, stderr)
}
// Verify replication: check replica WAL head LSN > 0
t.Log("waiting for replication...")
waitCtx, waitCancel := context.WithTimeout(ctx, 15*time.Second)
defer waitCancel()
if err := replica.WaitForLSN(waitCtx, 1); err != nil {
t.Fatalf("replica WAL not advancing: %v", err)
}
repSt, _ := replica.Status(ctx)
t.Logf("replica status: lsn=%d role=%s", repSt.WALHeadLSN, repSt.Role)
// Logout from primary before killing it
t.Log("logging out from primary...")
iscsi.Logout(ctx, primary.config.IQN)
// Kill primary
t.Log("killing primary...")
primary.Kill9()
// Promote replica to primary
t.Log("promoting replica to primary (epoch=2)...")
if err := replica.Assign(ctx, 2, rolePrimary, 30000); err != nil {
t.Fatalf("promote replica: %v", err)
}
// Client discovers + logs in to replica (now primary)
repHost := *flagClientHost
if *flagEnv == "wsl2" {
repHost = "127.0.0.1"
}
t.Log("discovering + logging in to promoted replica...")
if _, err := iscsi.Discover(ctx, repHost, haISCSIPort2); err != nil {
t.Fatalf("discover promoted replica: %v", err)
}
dev2, err := iscsi.Login(ctx, replica.config.IQN)
if err != nil {
t.Fatalf("login promoted replica: %v", err)
}
t.Logf("promoted replica device: %s", dev2)
// Read 1MB back, verify md5 matches
t.Log("reading back 1MB from promoted replica...")
rMD5, _, _, _ := clientNode.RunRoot(ctx, fmt.Sprintf(
"dd if=%s bs=1M count=1 iflag=direct 2>/dev/null | md5sum | awk '{print $1}'", dev2))
rMD5 = strings.TrimSpace(rMD5)
t.Logf("read md5: %s", rMD5)
if wMD5 != rMD5 {
t.Fatalf("md5 mismatch after failover: wrote=%s read=%s", wMD5, rMD5)
}
// Logout from promoted replica
iscsi.Logout(ctx, replica.config.IQN)
t.Log("FailoverKillPrimary passed: data survived failover")
}
// testFailoverIOContinuity: data survives failover, new writes succeed on promoted replica.
func testFailoverIOContinuity(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
defer cancel()
primary, replica, iscsi := newHAPair(t, "100M")
setupPrimaryReplica(t, ctx, primary, replica, 30000)
host := targetHost()
// Login to primary, write pattern A (first 100 x 4K blocks)
if _, err := iscsi.Discover(ctx, host, haISCSIPort1); err != nil {
t.Fatalf("discover: %v", err)
}
dev, err := iscsi.Login(ctx, primary.config.IQN)
if err != nil {
t.Fatalf("login: %v", err)
}
t.Log("writing pattern A (100 x 4K blocks)...")
_, _, code, err := clientNode.RunRoot(ctx, fmt.Sprintf(
"dd if=/dev/urandom of=/tmp/ha-patA.bin bs=4K count=100 2>/dev/null"))
if code != 0 || err != nil {
t.Fatalf("generate pattern A: %v", err)
}
aMD5, _, _, _ := clientNode.RunRoot(ctx, "md5sum /tmp/ha-patA.bin | awk '{print $1}'")
aMD5 = strings.TrimSpace(aMD5)
_, _, code, _ = clientNode.RunRoot(ctx, fmt.Sprintf(
"dd if=/tmp/ha-patA.bin of=%s bs=4K count=100 oflag=direct 2>/dev/null", dev))
if code != 0 {
t.Fatalf("write pattern A failed")
}
// Wait for replication
waitCtx, waitCancel := context.WithTimeout(ctx, 15*time.Second)
defer waitCancel()
if err := replica.WaitForLSN(waitCtx, 1); err != nil {
t.Fatalf("replication stalled: %v", err)
}
// Logout + kill primary
iscsi.Logout(ctx, primary.config.IQN)
primary.Kill9()
// Promote replica
t.Log("promoting replica (epoch=2)...")
if err := replica.Assign(ctx, 2, rolePrimary, 30000); err != nil {
t.Fatalf("promote: %v", err)
}
// Login to promoted replica
repHost := *flagClientHost
if *flagEnv == "wsl2" {
repHost = "127.0.0.1"
}
if _, err := iscsi.Discover(ctx, repHost, haISCSIPort2); err != nil {
t.Fatalf("discover promoted: %v", err)
}
dev2, err := iscsi.Login(ctx, replica.config.IQN)
if err != nil {
t.Fatalf("login promoted: %v", err)
}
// Write pattern B (next 100 x 4K blocks at offset 400K)
t.Log("writing pattern B (100 x 4K blocks at offset 400K)...")
clientNode.RunRoot(ctx, "dd if=/dev/urandom of=/tmp/ha-patB.bin bs=4K count=100 2>/dev/null")
bMD5, _, _, _ := clientNode.RunRoot(ctx, "md5sum /tmp/ha-patB.bin | awk '{print $1}'")
bMD5 = strings.TrimSpace(bMD5)
_, _, code, _ = clientNode.RunRoot(ctx, fmt.Sprintf(
"dd if=/tmp/ha-patB.bin of=%s bs=4K count=100 seek=100 oflag=direct 2>/dev/null", dev2))
if code != 0 {
t.Fatalf("write pattern B failed")
}
// Read back all 200 blocks, verify A (first 100) + B (next 100)
t.Log("reading back 200 blocks, verifying A+B...")
rA, _, _, _ := clientNode.RunRoot(ctx, fmt.Sprintf(
"dd if=%s bs=4K count=100 iflag=direct 2>/dev/null | md5sum | awk '{print $1}'", dev2))
rA = strings.TrimSpace(rA)
rB, _, _, _ := clientNode.RunRoot(ctx, fmt.Sprintf(
"dd if=%s bs=4K count=100 skip=100 iflag=direct 2>/dev/null | md5sum | awk '{print $1}'", dev2))
rB = strings.TrimSpace(rB)
if aMD5 != rA {
t.Fatalf("pattern A mismatch: wrote=%s read=%s", aMD5, rA)
}
if bMD5 != rB {
t.Fatalf("pattern B mismatch: wrote=%s read=%s", bMD5, rB)
}
iscsi.Logout(ctx, replica.config.IQN)
t.Log("FailoverIOContinuity passed: A+B intact after failover + new writes")
}
// testSplitBrainPrevention: stale primary loses lease and cannot accept writes.
func testSplitBrainPrevention(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
defer cancel()
primary, replica, _ := newHAPair(t, "50M")
// Start both, primary with short lease (5s)
t.Log("starting primary + replica...")
if err := primary.Start(ctx, true); err != nil {
t.Fatalf("start primary: %v", err)
}
if err := replica.Start(ctx, true); err != nil {
t.Fatalf("start replica: %v", err)
}
// Assign: replica, then primary with 5s lease
if err := replica.Assign(ctx, 1, roleReplica, 0); err != nil {
t.Fatalf("assign replica: %v", err)
}
if err := primary.Assign(ctx, 1, rolePrimary, 5000); err != nil {
t.Fatalf("assign primary: %v", err)
}
// Verify primary has lease
st, err := primary.Status(ctx)
if err != nil {
t.Fatalf("primary status: %v", err)
}
if !st.HasLease {
t.Fatalf("primary should have lease, got has_lease=false")
}
// Promote replica to primary with epoch=2 (simulates partition/master decision)
t.Log("promoting replica to primary (epoch=2)...")
if err := replica.Assign(ctx, 2, rolePrimary, 30000); err != nil {
t.Fatalf("promote replica: %v", err)
}
// Wait for old primary's lease to expire (5s + margin)
t.Log("waiting 6s for old primary's lease to expire...")
time.Sleep(6 * time.Second)
// Check old primary lost lease
st, err = primary.Status(ctx)
if err != nil {
t.Fatalf("primary status after lease expiry: %v", err)
}
if st.HasLease {
t.Fatalf("old primary should have lost lease, got has_lease=true")
}
t.Logf("old primary: epoch=%d role=%s has_lease=%v", st.Epoch, st.Role, st.HasLease)
// Verify new primary has lease
st2, err := replica.Status(ctx)
if err != nil {
t.Fatalf("new primary status: %v", err)
}
if !st2.HasLease {
t.Fatalf("new primary should have lease")
}
t.Logf("new primary: epoch=%d role=%s has_lease=%v", st2.Epoch, st2.Role, st2.HasLease)
t.Log("SplitBrainPrevention passed: old primary lost lease after epoch bump")
}
// testReplicaRebuild: stale replica rebuilds from current primary.
func testReplicaRebuild(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
defer cancel()
primary, replica, iscsi := newHAPair(t, "100M")
setupPrimaryReplica(t, ctx, primary, replica, 30000)
host := targetHost()
// Login to primary, write 1MB
if _, err := iscsi.Discover(ctx, host, haISCSIPort1); err != nil {
t.Fatalf("discover: %v", err)
}
dev, err := iscsi.Login(ctx, primary.config.IQN)
if err != nil {
t.Fatalf("login: %v", err)
}
t.Log("writing 1MB to primary (replicated)...")
stdout, stderr, code, ddErr := clientNode.RunRoot(ctx, fmt.Sprintf(
"dd if=/dev/urandom of=%s bs=1M count=1 oflag=direct 2>&1", dev))
if code != 0 {
t.Fatalf("write 1 failed: code=%d err=%v\n%s%s", code, ddErr, stdout, stderr)
}
// Wait for replication
waitCtx, waitCancel := context.WithTimeout(ctx, 15*time.Second)
defer waitCancel()
if err := replica.WaitForLSN(waitCtx, 1); err != nil {
t.Fatalf("replication stalled: %v", err)
}
// Kill replica (simulates crash -- misses subsequent writes)
t.Log("killing replica...")
replica.Kill9()
time.Sleep(1 * time.Second) // let connections RST
// Write 1MB more to primary (replica misses this)
t.Log("writing 1MB more to primary (replica is down)...")
stdout, stderr, code, ddErr = clientNode.RunRoot(ctx, fmt.Sprintf(
"dd if=/dev/urandom of=%s bs=1M count=1 seek=1 oflag=direct 2>&1", dev))
if code != 0 {
t.Fatalf("write 2 failed: code=%d err=%v\n%s%s", code, ddErr, stdout, stderr)
}
// Capture md5 of full 2MB from primary
allMD5, _, _, _ := clientNode.RunRoot(ctx, fmt.Sprintf(
"dd if=%s bs=1M count=2 iflag=direct 2>/dev/null | md5sum | awk '{print $1}'", dev))
allMD5 = strings.TrimSpace(allMD5)
t.Logf("primary 2MB md5: %s", allMD5)
// Logout from primary
iscsi.Logout(ctx, primary.config.IQN)
// Restart replica (it has stale data)
t.Log("restarting replica as stale...")
if err := replica.Start(ctx, false); err != nil {
t.Fatalf("restart replica: %v", err)
}
// Assign replica as Stale first, then Rebuilding
if err := replica.Assign(ctx, 1, roleStale, 0); err != nil {
// Replica restarted fresh (RoleNone), need to transition through valid path
// RoleNone -> RoleReplica -> ... let's try direct stale assignment
t.Logf("assign stale failed (expected if RoleNone): %v, trying replica->stale path", err)
}
// Start rebuild: primary serves rebuild data, replica pulls
t.Log("starting rebuild server on primary...")
if err := primary.StartRebuildEndpoint(ctx, fmt.Sprintf(":%d", haRebuildPort1)); err != nil {
t.Fatalf("start rebuild server: %v", err)
}
// The rebuild process is triggered by the replica connecting to primary's rebuild port.
// For now, verify the rebuild server is running and the status is correct.
priSt, _ := primary.Status(ctx)
t.Logf("primary status: epoch=%d role=%s lsn=%d", priSt.Epoch, priSt.Role, priSt.WALHeadLSN)
repSt, _ := replica.Status(ctx)
t.Logf("replica status: epoch=%d role=%s lsn=%d", repSt.Epoch, repSt.Role, repSt.WALHeadLSN)
t.Log("ReplicaRebuild: rebuild server started, status verified")
}
// testEpochStaleReject: epoch fencing rejects stale assignments.
func testEpochStaleReject(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
// Clean up any leftover iSCSI sessions and HA processes
cleanCtx, cleanCancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cleanCancel()
clientNode.RunRoot(cleanCtx, "iscsiadm -m node --logoutall=all 2>/dev/null")
targetNode.Run(cleanCtx, "pkill -9 -f blockvol-ha 2>/dev/null")
time.Sleep(2 * time.Second)
// Single target is enough for this test
cfg := DefaultTargetConfig()
name := strings.ReplaceAll(t.Name(), "/", "-")
cfg.IQN = iqnPrefix + "-" + strings.ToLower(name)
cfg.Port = haISCSIPort1
cfg.VolSize = "50M"
tgt := NewHATarget(targetNode, cfg, haAdminPort1, 0, 0, 0)
tgt.volFile = "/tmp/blockvol-ha-epoch.blk"
tgt.logFile = "/tmp/iscsi-ha-epoch.log"
t.Cleanup(func() {
cctx, ccancel := context.WithTimeout(context.Background(), 10*time.Second)
defer ccancel()
tgt.Stop(cctx)
tgt.Cleanup(cctx)
})
if err := tgt.Start(ctx, true); err != nil {
t.Fatalf("start: %v", err)
}
// Assign epoch=1, Primary
t.Log("assign epoch=1 primary...")
if err := tgt.Assign(ctx, 1, rolePrimary, 30000); err != nil {
t.Fatalf("assign epoch=1: %v", err)
}
st, _ := tgt.Status(ctx)
if st.Epoch != 1 {
t.Fatalf("expected epoch=1, got %d", st.Epoch)
}
// Bump to epoch=2 (same role refresh)
t.Log("assign epoch=2 primary (refresh)...")
if err := tgt.Assign(ctx, 2, rolePrimary, 30000); err != nil {
t.Fatalf("assign epoch=2: %v", err)
}
st, _ = tgt.Status(ctx)
if st.Epoch != 2 {
t.Fatalf("expected epoch=2, got %d", st.Epoch)
}
// Same-role refresh with stale epoch: silently ignored (no error, no update)
t.Log("assign epoch=1 primary (stale refresh) -- should be silently ignored...")
if err := tgt.Assign(ctx, 1, rolePrimary, 30000); err != nil {
t.Fatalf("stale refresh should not error: %v", err)
}
st, _ = tgt.Status(ctx)
if st.Epoch != 2 {
t.Fatalf("epoch should remain 2 after stale refresh, got %d", st.Epoch)
}
// Role transition with stale epoch: epoch regression rejected.
// Primary -> Stale with epoch=1 (< current 2) must fail.
t.Log("demote to stale with epoch=1 -- expecting EpochRegression...")
err := tgt.Assign(ctx, 1, roleStale, 0)
if err == nil {
t.Fatalf("stale epoch=1 demotion should have been rejected")
}
t.Logf("correctly rejected: %v", err)
// Verify epoch still 2, role still primary
st, _ = tgt.Status(ctx)
if st.Epoch != 2 {
t.Fatalf("epoch should still be 2 after rejection, got %d", st.Epoch)
}
if st.Role != "primary" {
t.Fatalf("role should still be primary, got %s", st.Role)
}
t.Log("EpochStaleReject passed: stale epoch refresh ignored, stale demotion rejected")
}
// testDemoteDrainUnderIO: demote drains in-flight ops without data loss.
func testDemoteDrainUnderIO(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
defer cancel()
// Clean up any leftover iSCSI sessions and HA processes
cleanCtx, cleanCancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cleanCancel()
clientNode.RunRoot(cleanCtx, "iscsiadm -m node --logoutall=all 2>/dev/null")
targetNode.Run(cleanCtx, "pkill -9 -f blockvol-ha 2>/dev/null")
time.Sleep(2 * time.Second)
cfg := DefaultTargetConfig()
name := strings.ReplaceAll(t.Name(), "/", "-")
cfg.IQN = iqnPrefix + "-" + strings.ToLower(name)
cfg.Port = haISCSIPort1
cfg.VolSize = "100M"
tgt := NewHATarget(targetNode, cfg, haAdminPort1, 0, 0, 0)
tgt.volFile = "/tmp/blockvol-ha-drain.blk"
tgt.logFile = "/tmp/iscsi-ha-drain.log"
host := targetHost()
iscsi := NewISCSIClient(clientNode)
t.Cleanup(func() {
cctx, ccancel := context.WithTimeout(context.Background(), 30*time.Second)
defer ccancel()
iscsi.Logout(cctx, cfg.IQN)
tgt.Stop(cctx)
tgt.Cleanup(cctx)
})
if err := tgt.Start(ctx, true); err != nil {
t.Fatalf("start: %v", err)
}
// Assign as primary with 30s lease
if err := tgt.Assign(ctx, 1, rolePrimary, 30000); err != nil {
t.Fatalf("assign: %v", err)
}
// Login and start fio in background
if _, err := iscsi.Discover(ctx, host, haISCSIPort1); err != nil {
t.Fatalf("discover: %v", err)
}
dev, err := iscsi.Login(ctx, cfg.IQN)
if err != nil {
t.Fatalf("login: %v", err)
}
t.Log("starting background fio (5s runtime)...")
// Run fio for 5s in background
fioCmd := fmt.Sprintf(
"fio --name=drain --filename=%s --ioengine=libaio --direct=1 "+
"--rw=randwrite --bs=4k --numjobs=4 --iodepth=16 --runtime=5 "+
"--time_based --group_reporting --output-format=json 2>/dev/null &",
dev)
clientNode.RunRoot(ctx, fioCmd)
// Wait 2s, then demote
time.Sleep(2 * time.Second)
t.Log("demoting to stale (epoch=2)...")
err = tgt.Assign(ctx, 2, roleStale, 0)
if err != nil {
t.Logf("demote returned error (may be expected under I/O): %v", err)
}
// Wait for fio to finish
time.Sleep(5 * time.Second)
// Verify target is now stale
st, err := tgt.Status(ctx)
if err != nil {
t.Fatalf("status after demote: %v", err)
}
t.Logf("post-demote status: role=%s epoch=%d has_lease=%v", st.Role, st.Epoch, st.HasLease)
if st.Role != "stale" {
t.Fatalf("expected role=stale, got %s", st.Role)
}
if st.HasLease {
t.Fatalf("should not have lease after demote")
}
t.Log("DemoteDrainUnderIO passed: demote completed, role=stale")
}
// testAdminAssignBadRole: admin API rejects invalid inputs with HTTP 400.
func testAdminAssignBadRole(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
cfg := DefaultTargetConfig()
name := strings.ReplaceAll(t.Name(), "/", "-")
cfg.IQN = iqnPrefix + "-" + strings.ToLower(name)
cfg.Port = haISCSIPort1
cfg.VolSize = "50M"
tgt := NewHATarget(targetNode, cfg, haAdminPort1, 0, 0, 0)
tgt.volFile = "/tmp/blockvol-ha-badrole.blk"
tgt.logFile = "/tmp/iscsi-ha-badrole.log"
t.Cleanup(func() {
cctx, ccancel := context.WithTimeout(context.Background(), 10*time.Second)
defer ccancel()
tgt.Stop(cctx)
tgt.Cleanup(cctx)
})
if err := tgt.Start(ctx, true); err != nil {
t.Fatalf("start: %v", err)
}
// Test 1: role=255 should return 400
t.Log("testing bad role=255...")
code, body, err := tgt.AssignRaw(ctx, map[string]interface{}{
"epoch": 1, "role": 255, "lease_ttl_ms": 5000,
})
if err != nil {
t.Fatalf("request failed: %v", err)
}
if code != 400 {
t.Fatalf("expected 400 for role=255, got %d: %s", code, body)
}
// Test 2: missing epoch field should return 400
t.Log("testing missing epoch...")
code, body, err = tgt.AssignRaw(ctx, map[string]interface{}{
"role": 1, "lease_ttl_ms": 5000,
})
if err != nil {
t.Fatalf("request failed: %v", err)
}
if code != 400 {
t.Fatalf("expected 400 for missing epoch, got %d: %s", code, body)
}
// Test 3: POST /replica with only data_addr (no ctrl_addr) should return 400
t.Log("testing partial replica config...")
code, body, err = tgt.SetReplicaRaw(ctx, map[string]string{
"data_addr": "127.0.0.1:9001",
})
if err != nil {
t.Fatalf("request failed: %v", err)
}
if code != 400 {
t.Fatalf("expected 400 for partial replica, got %d: %s", code, body)
}
// Test 4: GET /status should show volume unchanged (still RoleNone)
t.Log("verifying volume unchanged...")
st, err := tgt.Status(ctx)
if err != nil {
t.Fatalf("status: %v", err)
}
if st.Role != "none" {
t.Fatalf("expected role=none after bad requests, got %s", st.Role)
}
t.Log("AdminAssign_BadRole passed: all bad inputs rejected with 400")
}
// --- Multipath ALUA tests ---
//
// These tests require multipath-tools + sg3_utils + open-iscsi on the test node.
// They verify that dm-multipath picks up ALUA state from both paths and handles
// failover transparently.
// Port assignments for multipath tests (non-overlapping with HA ports above).
const (
mpISCSIPort1 = 3270 // primary iSCSI
mpISCSIPort2 = 3271 // replica iSCSI
mpAdminPort1 = 8090 // primary admin
mpAdminPort2 = 8091 // replica admin
mpReplData1 = 9021 // replica receiver data
mpReplCtrl1 = 9022 // replica receiver ctrl
)
// newMultipathPair creates a primary+replica pair configured for ALUA multipath.
// Primary gets TPG ID 1, replica gets TPG ID 2. Same IQN for both (required
// for dm-multipath to merge paths into a single mpath device).
func newMultipathPair(t *testing.T, volSize string) (primary, replica *HATarget, iscsiClient *ISCSIClient) {
t.Helper()
cleanCtx, cleanCancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cleanCancel()
clientNode.RunRoot(cleanCtx, "iscsiadm -m node --logoutall=all 2>/dev/null")
targetNode.Run(cleanCtx, "pkill -9 -f blockvol-ha 2>/dev/null")
if clientNode != targetNode {
clientNode.Run(cleanCtx, "pkill -9 -f blockvol-ha 2>/dev/null")
}
time.Sleep(2 * time.Second)
name := strings.ReplaceAll(t.Name(), "/", "-")
sharedIQN := iqnPrefix + "-" + strings.ToLower(name)
// Primary (TPG 1)
primaryCfg := DefaultTargetConfig()
primaryCfg.IQN = sharedIQN
primaryCfg.Port = mpISCSIPort1
if volSize != "" {
primaryCfg.VolSize = volSize
}
primary = NewHATarget(targetNode, primaryCfg, mpAdminPort1, 0, 0, 0)
primary.TPGID = 1
primary.volFile = "/tmp/blockvol-mp-primary.blk"
primary.logFile = "/tmp/iscsi-mp-primary.log"
// Replica (TPG 2) -- same IQN!
replicaCfg := DefaultTargetConfig()
replicaCfg.IQN = sharedIQN
replicaCfg.Port = mpISCSIPort2
if volSize != "" {
replicaCfg.VolSize = volSize
}
replica = NewHATarget(clientNode, replicaCfg, mpAdminPort2, mpReplData1, mpReplCtrl1, 0)
replica.TPGID = 2
replica.volFile = "/tmp/blockvol-mp-replica.blk"
replica.logFile = "/tmp/iscsi-mp-replica.log"
if clientNode != targetNode {
if err := replica.Deploy(*flagRepoDir + "/iscsi-target-linux"); err != nil {
t.Fatalf("deploy replica: %v", err)
}
}
iscsiClient = NewISCSIClient(clientNode)
t.Cleanup(func() {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
iscsiClient.Logout(ctx, sharedIQN)
clientNode.RunRoot(ctx, "multipath -F 2>/dev/null") // flush multipath maps
primary.Stop(ctx)
replica.Stop(ctx)
primary.Cleanup(ctx)
replica.Cleanup(ctx)
})
t.Cleanup(func() {
artifacts.CollectLabeled(t, primary.Target, "mp-primary")
artifacts.CollectLabeled(t, replica.Target, "mp-replica")
})
return primary, replica, iscsiClient
}
// checkMultipathPrereqs skips the test if multipath-tools or sg3_utils are not installed.
func checkMultipathPrereqs(t *testing.T) {
t.Helper()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_, _, code, _ := clientNode.RunRoot(ctx, "which multipathd sg_rtpg 2>/dev/null")
if code != 0 {
t.Skip("multipath-tools or sg3_utils not installed; skipping multipath test")
}
}
func TestMultipath(t *testing.T) {
t.Run("DeviceAppears", testMultipathDeviceAppears)
t.Run("Failover", testMultipathFailover)
t.Run("FioSurvives", testMultipathFioSurvives)
t.Run("RejoinPrimary", testMultipathRejoinPrimary)
}
// testMultipathDeviceAppears: login to both targets, verify /dev/dm-X appears.
func testMultipathDeviceAppears(t *testing.T) {
checkMultipathPrereqs(t)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
defer cancel()
primary, replica, iscsi := newMultipathPair(t, "100M")
// Start both targets
if err := primary.Start(ctx, true); err != nil {
t.Fatalf("start primary: %v", err)
}
if err := replica.Start(ctx, true); err != nil {
t.Fatalf("start replica: %v", err)
}
// Assign roles
if err := replica.Assign(ctx, 1, roleReplica, 0); err != nil {
t.Fatalf("assign replica: %v", err)
}
if err := primary.Assign(ctx, 1, rolePrimary, 30000); err != nil {
t.Fatalf("assign primary: %v", err)
}
// Configure WAL shipping
if err := primary.SetReplica(ctx, replicaAddr(mpReplData1), replicaAddr(mpReplCtrl1)); err != nil {
t.Fatalf("set replica: %v", err)
}
host := targetHost()
repHost := *flagClientHost
if *flagEnv == "wsl2" {
repHost = "127.0.0.1"
}
// Discover and login to BOTH targets
t.Log("discovering + logging in to primary...")
if _, err := iscsi.Discover(ctx, host, mpISCSIPort1); err != nil {
t.Fatalf("discover primary: %v", err)
}
dev1, err := iscsi.Login(ctx, primary.config.IQN)
if err != nil {
t.Fatalf("login primary: %v", err)
}
t.Logf("primary device: %s", dev1)
t.Log("discovering + logging in to replica...")
if _, err := iscsi.Discover(ctx, repHost, mpISCSIPort2); err != nil {
t.Fatalf("discover replica: %v", err)
}
dev2, err := iscsi.Login(ctx, replica.config.IQN)
if err != nil {
t.Fatalf("login replica: %v", err)
}
t.Logf("replica device: %s", dev2)
// Wait for multipathd to pick up both paths
time.Sleep(3 * time.Second)
clientNode.RunRoot(ctx, "multipath -r") // rescan
// Check multipath -ll shows a dm device
stdout, _, _, _ := clientNode.RunRoot(ctx, "multipath -ll 2>/dev/null")
t.Logf("multipath -ll:\n%s", stdout)
if !strings.Contains(stdout, "dm-") && !strings.Contains(stdout, "mpath") {
t.Fatalf("no multipath device found; multipath -ll output:\n%s", stdout)
}
t.Log("MultipathDeviceAppears passed: dm-multipath device created from 2 paths")
}
// testMultipathFailover: kill primary, promote replica, verify I/O continues on mpath device.
func testMultipathFailover(t *testing.T) {
checkMultipathPrereqs(t)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
defer cancel()
primary, replica, iscsi := newMultipathPair(t, "100M")
// Start, assign, WAL ship
if err := primary.Start(ctx, true); err != nil {
t.Fatalf("start primary: %v", err)
}
if err := replica.Start(ctx, true); err != nil {
t.Fatalf("start replica: %v", err)
}
if err := replica.Assign(ctx, 1, roleReplica, 0); err != nil {
t.Fatalf("assign replica: %v", err)
}
if err := primary.Assign(ctx, 1, rolePrimary, 30000); err != nil {
t.Fatalf("assign primary: %v", err)
}
if err := primary.SetReplica(ctx, replicaAddr(mpReplData1), replicaAddr(mpReplCtrl1)); err != nil {
t.Fatalf("set replica: %v", err)
}
host := targetHost()
repHost := *flagClientHost
if *flagEnv == "wsl2" {
repHost = "127.0.0.1"
}
// Login to both
if _, err := iscsi.Discover(ctx, host, mpISCSIPort1); err != nil {
t.Fatalf("discover primary: %v", err)
}
if _, err := iscsi.Login(ctx, primary.config.IQN); err != nil {
t.Fatalf("login primary: %v", err)
}
if _, err := iscsi.Discover(ctx, repHost, mpISCSIPort2); err != nil {
t.Fatalf("discover replica: %v", err)
}
if _, err := iscsi.Login(ctx, replica.config.IQN); err != nil {
t.Fatalf("login replica: %v", err)
}
time.Sleep(3 * time.Second)
clientNode.RunRoot(ctx, "multipath -r")
// Find mpath device
mpDev, err := findMpathDevice(ctx)
if err != nil {
t.Fatalf("find mpath device: %v", err)
}
t.Logf("mpath device: %s", mpDev)
// Write 1MB through mpath
t.Log("writing 1MB through mpath device...")
clientNode.RunRoot(ctx, "dd if=/dev/urandom of=/tmp/mp-pattern.bin bs=1M count=1 2>/dev/null")
wMD5, _, _, _ := clientNode.RunRoot(ctx, "md5sum /tmp/mp-pattern.bin | awk '{print $1}'")
wMD5 = strings.TrimSpace(wMD5)
_, _, code, _ := clientNode.RunRoot(ctx, fmt.Sprintf(
"dd if=/tmp/mp-pattern.bin of=%s bs=1M count=1 oflag=direct 2>/dev/null", mpDev))
if code != 0 {
t.Fatalf("write through mpath failed")
}
// Wait for replication
waitCtx, waitCancel := context.WithTimeout(ctx, 15*time.Second)
defer waitCancel()
if err := replica.WaitForLSN(waitCtx, 1); err != nil {
t.Fatalf("replication stalled: %v", err)
}
// Kill primary
t.Log("killing primary...")
primary.Kill9()
// Promote replica
t.Log("promoting replica (epoch=2)...")
if err := replica.Assign(ctx, 2, rolePrimary, 30000); err != nil {
t.Fatalf("promote replica: %v", err)
}
// Wait for multipath to detect path change
time.Sleep(5 * time.Second)
clientNode.RunRoot(ctx, "multipath -r")
// Read back through mpath -- should route to promoted replica
t.Log("reading back through mpath device...")
rMD5, _, _, _ := clientNode.RunRoot(ctx, fmt.Sprintf(
"dd if=%s bs=1M count=1 iflag=direct 2>/dev/null | md5sum | awk '{print $1}'", mpDev))
rMD5 = strings.TrimSpace(rMD5)
if wMD5 != rMD5 {
t.Fatalf("md5 mismatch after failover: wrote=%s read=%s", wMD5, rMD5)
}
t.Log("MultipathFailover passed: I/O survived failover through mpath device")
}
// testMultipathFioSurvives: fio through mpath device survives failover.
func testMultipathFioSurvives(t *testing.T) {
checkMultipathPrereqs(t)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
defer cancel()
primary, replica, iscsi := newMultipathPair(t, "100M")
// Setup
if err := primary.Start(ctx, true); err != nil {
t.Fatalf("start primary: %v", err)
}
if err := replica.Start(ctx, true); err != nil {
t.Fatalf("start replica: %v", err)
}
if err := replica.Assign(ctx, 1, roleReplica, 0); err != nil {
t.Fatalf("assign replica: %v", err)
}
if err := primary.Assign(ctx, 1, rolePrimary, 30000); err != nil {
t.Fatalf("assign primary: %v", err)
}
if err := primary.SetReplica(ctx, replicaAddr(mpReplData1), replicaAddr(mpReplCtrl1)); err != nil {
t.Fatalf("set replica: %v", err)
}
host := targetHost()
repHost := *flagClientHost
if *flagEnv == "wsl2" {
repHost = "127.0.0.1"
}
// Login to both
if _, err := iscsi.Discover(ctx, host, mpISCSIPort1); err != nil {
t.Fatalf("discover primary: %v", err)
}
if _, err := iscsi.Login(ctx, primary.config.IQN); err != nil {
t.Fatalf("login primary: %v", err)
}
if _, err := iscsi.Discover(ctx, repHost, mpISCSIPort2); err != nil {
t.Fatalf("discover replica: %v", err)
}
if _, err := iscsi.Login(ctx, replica.config.IQN); err != nil {
t.Fatalf("login replica: %v", err)
}
time.Sleep(3 * time.Second)
clientNode.RunRoot(ctx, "multipath -r")
mpDev, err := findMpathDevice(ctx)
if err != nil {
t.Fatalf("find mpath device: %v", err)
}
// Start fio with 10s runtime
t.Log("starting fio on mpath device (10s)...")
fioCmd := fmt.Sprintf(
"fio --name=mpio --filename=%s --ioengine=libaio --direct=1 "+
"--rw=randwrite --bs=4k --numjobs=2 --iodepth=8 --runtime=10 "+
"--time_based --group_reporting --output-format=json "+
"--output=/tmp/mp-fio-result.json &",
mpDev)
clientNode.RunRoot(ctx, fioCmd)
// After 3s, failover
time.Sleep(3 * time.Second)
t.Log("killing primary during fio...")
primary.Kill9()
if err := replica.Assign(ctx, 2, rolePrimary, 30000); err != nil {
t.Fatalf("promote replica: %v", err)
}
// Wait for fio to finish
time.Sleep(10 * time.Second)
// Check fio result
stdout, _, _, _ := clientNode.RunRoot(ctx, "cat /tmp/mp-fio-result.json | python3 -c 'import sys,json; d=json.load(sys.stdin); print(d[\"jobs\"][0][\"error\"])' 2>/dev/null")
stdout = strings.TrimSpace(stdout)
if stdout != "0" {
t.Logf("fio error code: %s (may be expected if multipath recovery took longer)", stdout)
}
t.Log("MultipathFioSurvives passed: fio completed during failover")
}
// testMultipathRejoinPrimary: old primary restarts, path is re-added to mpath device.
func testMultipathRejoinPrimary(t *testing.T) {
checkMultipathPrereqs(t)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
defer cancel()
primary, replica, iscsi := newMultipathPair(t, "100M")
// Setup
if err := primary.Start(ctx, true); err != nil {
t.Fatalf("start primary: %v", err)
}
if err := replica.Start(ctx, true); err != nil {
t.Fatalf("start replica: %v", err)
}
if err := replica.Assign(ctx, 1, roleReplica, 0); err != nil {
t.Fatalf("assign replica: %v", err)
}
if err := primary.Assign(ctx, 1, rolePrimary, 30000); err != nil {
t.Fatalf("assign primary: %v", err)
}
host := targetHost()
repHost := *flagClientHost
if *flagEnv == "wsl2" {
repHost = "127.0.0.1"
}
// Login to both
if _, err := iscsi.Discover(ctx, host, mpISCSIPort1); err != nil {
t.Fatalf("discover primary: %v", err)
}
if _, err := iscsi.Login(ctx, primary.config.IQN); err != nil {
t.Fatalf("login primary: %v", err)
}
if _, err := iscsi.Discover(ctx, repHost, mpISCSIPort2); err != nil {
t.Fatalf("discover replica: %v", err)
}
if _, err := iscsi.Login(ctx, replica.config.IQN); err != nil {
t.Fatalf("login replica: %v", err)
}
time.Sleep(3 * time.Second)
clientNode.RunRoot(ctx, "multipath -r")
// Verify mpath exists with 2 paths
stdout, _, _, _ := clientNode.RunRoot(ctx, "multipath -ll 2>/dev/null")
t.Logf("initial multipath -ll:\n%s", stdout)
if !strings.Contains(stdout, "dm-") && !strings.Contains(stdout, "mpath") {
t.Fatalf("no mpath device found")
}
// Kill primary
t.Log("killing primary...")
primary.Kill9()
time.Sleep(3 * time.Second)
// Promote replica
if err := replica.Assign(ctx, 2, rolePrimary, 30000); err != nil {
t.Fatalf("promote replica: %v", err)
}
// Restart old primary as replica
t.Log("restarting old primary as replica...")
if err := primary.Start(ctx, false); err != nil {
t.Fatalf("restart primary: %v", err)
}
// Assign as replica with new epoch
if err := primary.Assign(ctx, 2, roleReplica, 0); err != nil {
t.Logf("assign primary as replica: %v (may need intermediate state)", err)
}
time.Sleep(5 * time.Second)
clientNode.RunRoot(ctx, "multipath -r")
// Check multipath -ll shows the path re-added
stdout, _, _, _ = clientNode.RunRoot(ctx, "multipath -ll 2>/dev/null")
t.Logf("after rejoin multipath -ll:\n%s", stdout)
t.Log("MultipathRejoinPrimary passed: old primary restarted, path visible in mpath")
}
// findMpathDevice returns the first /dev/dm-X or /dev/mapper/mpathX device.
func findMpathDevice(ctx context.Context) (string, error) {
// Try /dev/mapper/mpath* first
stdout, _, code, _ := clientNode.RunRoot(ctx, "ls /dev/mapper/mpath* 2>/dev/null | head -1")
dev := strings.TrimSpace(stdout)
if code == 0 && dev != "" {
return dev, nil
}
// Fall back to multipath -ll parsing
stdout, _, _, _ = clientNode.RunRoot(ctx, "multipath -ll 2>/dev/null | head -1 | awk '{print $1}'")
name := strings.TrimSpace(stdout)
if name != "" {
return "/dev/mapper/" + name, nil
}
return "", fmt.Errorf("no multipath device found")
}