mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-09-26 01:44:48 +00:00
Add HTTP admin server to iscsi-target binary (POST /assign, GET /status, POST /replica, POST /rebuild) and 7 HA integration tests validating failover, split-brain prevention, epoch fencing, and demote-under-IO. New files: - admin.go: HTTP admin endpoint with input validation - ha_target.go: HATarget helper wrapping Target + admin HTTP calls - ha_test.go: 7 HA tests (all PASS on WSL2, 67.7s total) Bug fixes: - BUG-CP4B4-1: CmdSN init (expCmdSN=0 not 1, first SCSI cmd was dropped) - BUG-CP4B4-2: RoleNone->RoleReplica missing SetEpoch (WAL rejected) - BUG-CP4B4-3: replica applyEntry didn't update vol.nextLSN (status=0) - BUG-CP4B4-4: PID discovery killed primary instead of replica (shared binPath; fixed by grepping volFile) - BUG-CP4B4-5: artifact collector overwrote primary log with replica log (added CollectLabeled method) Also: 3s write deadline on WAL shipper data connection to avoid 120s TCP retransmission timeout when replica is dead. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
305 lines
7.3 KiB
Go
305 lines
7.3 KiB
Go
package iscsi
|
|
|
|
import (
|
|
"encoding/binary"
|
|
"io"
|
|
"log"
|
|
"net"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func TestTarget(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
run func(t *testing.T)
|
|
}{
|
|
{"listen_and_connect", testListenAndConnect},
|
|
{"discovery_via_target", testDiscoveryViaTarget},
|
|
{"login_read_write", testTargetLoginReadWrite},
|
|
{"graceful_shutdown", testGracefulShutdown},
|
|
{"add_remove_volume", testAddRemoveVolume},
|
|
{"multiple_connections", testMultipleConnections},
|
|
{"connect_no_volumes", testConnectNoVolumes},
|
|
// Phase 3 bug fix: P3-BUG-6 shutdown race.
|
|
{"close_rejects_late_conn", testCloseRejectsLateConn},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
tt.run(t)
|
|
})
|
|
}
|
|
}
|
|
|
|
func setupTarget(t *testing.T) (*TargetServer, string) {
|
|
t.Helper()
|
|
config := DefaultTargetConfig()
|
|
config.TargetName = testTargetName
|
|
logger := log.New(io.Discard, "", 0)
|
|
ts := NewTargetServer("127.0.0.1:0", config, logger)
|
|
|
|
dev := newMockDevice(256 * 4096) // 256 blocks = 1MB
|
|
ts.AddVolume(testTargetName, dev)
|
|
|
|
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
go ts.Serve(ln)
|
|
|
|
addr := ln.Addr().String()
|
|
t.Cleanup(func() {
|
|
ts.Close()
|
|
})
|
|
|
|
return ts, addr
|
|
}
|
|
|
|
func dialTarget(t *testing.T, addr string) net.Conn {
|
|
t.Helper()
|
|
conn, err := net.DialTimeout("tcp", addr, 2*time.Second)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() { conn.Close() })
|
|
return conn
|
|
}
|
|
|
|
func loginToTarget(t *testing.T, conn net.Conn) {
|
|
t.Helper()
|
|
params := NewParams()
|
|
params.Set("InitiatorName", testInitiatorName)
|
|
params.Set("TargetName", testTargetName)
|
|
params.Set("SessionType", "Normal")
|
|
|
|
req := makeLoginReq(StageSecurityNeg, StageFullFeature, true, params)
|
|
req.SetCmdSN(1)
|
|
|
|
if err := WritePDU(conn, req); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
resp, err := ReadPDU(conn)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if resp.LoginStatusClass() != LoginStatusSuccess {
|
|
t.Fatalf("login failed: %d/%d", resp.LoginStatusClass(), resp.LoginStatusDetail())
|
|
}
|
|
}
|
|
|
|
func testListenAndConnect(t *testing.T) {
|
|
_, addr := setupTarget(t)
|
|
|
|
conn := dialTarget(t, addr)
|
|
loginToTarget(t, conn)
|
|
}
|
|
|
|
func testDiscoveryViaTarget(t *testing.T) {
|
|
_, addr := setupTarget(t)
|
|
|
|
conn := dialTarget(t, addr)
|
|
|
|
// Login as discovery
|
|
params := NewParams()
|
|
params.Set("InitiatorName", testInitiatorName)
|
|
params.Set("SessionType", "Discovery")
|
|
req := makeLoginReq(StageSecurityNeg, StageFullFeature, true, params)
|
|
req.SetCmdSN(1)
|
|
WritePDU(conn, req)
|
|
ReadPDU(conn)
|
|
|
|
// SendTargets=All
|
|
textParams := NewParams()
|
|
textParams.Set("SendTargets", "All")
|
|
textReq := makeTextReq(textParams)
|
|
textReq.SetCmdSN(0)
|
|
WritePDU(conn, textReq)
|
|
|
|
resp, err := ReadPDU(conn)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
body := string(resp.DataSegment)
|
|
if !strings.Contains(body, testTargetName) {
|
|
t.Fatalf("discovery response missing target: %q", body)
|
|
}
|
|
}
|
|
|
|
func testTargetLoginReadWrite(t *testing.T) {
|
|
_, addr := setupTarget(t)
|
|
conn := dialTarget(t, addr)
|
|
loginToTarget(t, conn)
|
|
|
|
// Write 1 block at LBA 0
|
|
cmd := &PDU{}
|
|
cmd.SetOpcode(OpSCSICmd)
|
|
cmd.SetOpSpecific1(FlagF | FlagW)
|
|
cmd.SetInitiatorTaskTag(1)
|
|
cmd.SetExpectedDataTransferLength(4096)
|
|
cmd.SetCmdSN(0)
|
|
var cdb [16]byte
|
|
cdb[0] = ScsiWrite10
|
|
binary.BigEndian.PutUint32(cdb[2:6], 0)
|
|
binary.BigEndian.PutUint16(cdb[7:9], 1)
|
|
cmd.SetCDB(cdb)
|
|
data := make([]byte, 4096)
|
|
for i := range data {
|
|
data[i] = 0xAB
|
|
}
|
|
cmd.DataSegment = data
|
|
|
|
WritePDU(conn, cmd)
|
|
resp, _ := ReadPDU(conn)
|
|
if resp.SCSIStatus() != SCSIStatusGood {
|
|
t.Fatalf("write failed: %d", resp.SCSIStatus())
|
|
}
|
|
|
|
// Read it back
|
|
cmd2 := &PDU{}
|
|
cmd2.SetOpcode(OpSCSICmd)
|
|
cmd2.SetOpSpecific1(FlagF | FlagR)
|
|
cmd2.SetInitiatorTaskTag(2)
|
|
cmd2.SetExpectedDataTransferLength(4096)
|
|
cmd2.SetCmdSN(1)
|
|
var cdb2 [16]byte
|
|
cdb2[0] = ScsiRead10
|
|
binary.BigEndian.PutUint32(cdb2[2:6], 0)
|
|
binary.BigEndian.PutUint16(cdb2[7:9], 1)
|
|
cmd2.SetCDB(cdb2)
|
|
|
|
WritePDU(conn, cmd2)
|
|
resp2, _ := ReadPDU(conn)
|
|
if resp2.Opcode() != OpSCSIDataIn {
|
|
t.Fatalf("expected Data-In, got %s", OpcodeName(resp2.Opcode()))
|
|
}
|
|
if resp2.DataSegment[0] != 0xAB {
|
|
t.Fatal("data mismatch")
|
|
}
|
|
}
|
|
|
|
func testGracefulShutdown(t *testing.T) {
|
|
ts, addr := setupTarget(t)
|
|
|
|
conn := dialTarget(t, addr)
|
|
loginToTarget(t, conn)
|
|
|
|
// Close the target -- should shut down cleanly
|
|
ts.Close()
|
|
|
|
// Connection should be dropped
|
|
_, err := ReadPDU(conn)
|
|
if err == nil {
|
|
t.Fatal("expected error after shutdown")
|
|
}
|
|
}
|
|
|
|
func testAddRemoveVolume(t *testing.T) {
|
|
config := DefaultTargetConfig()
|
|
logger := log.New(io.Discard, "", 0)
|
|
ts := NewTargetServer("127.0.0.1:0", config, logger)
|
|
|
|
ts.AddVolume("iqn.2024.com.test:v1", newMockDevice(1024*4096))
|
|
ts.AddVolume("iqn.2024.com.test:v2", newMockDevice(1024*4096))
|
|
|
|
if !ts.HasTarget("iqn.2024.com.test:v1") {
|
|
t.Fatal("v1 should exist")
|
|
}
|
|
|
|
ts.RemoveVolume("iqn.2024.com.test:v1")
|
|
if ts.HasTarget("iqn.2024.com.test:v1") {
|
|
t.Fatal("v1 should be removed")
|
|
}
|
|
if !ts.HasTarget("iqn.2024.com.test:v2") {
|
|
t.Fatal("v2 should still exist")
|
|
}
|
|
|
|
targets := ts.ListTargets()
|
|
if len(targets) != 1 {
|
|
t.Fatalf("expected 1 target, got %d", len(targets))
|
|
}
|
|
}
|
|
|
|
func testMultipleConnections(t *testing.T) {
|
|
_, addr := setupTarget(t)
|
|
|
|
// Connect multiple clients
|
|
for i := 0; i < 5; i++ {
|
|
conn := dialTarget(t, addr)
|
|
loginToTarget(t, conn)
|
|
}
|
|
}
|
|
|
|
func testConnectNoVolumes(t *testing.T) {
|
|
config := DefaultTargetConfig()
|
|
logger := log.New(io.Discard, "", 0)
|
|
ts := NewTargetServer("127.0.0.1:0", config, logger)
|
|
// No volumes added
|
|
|
|
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
go ts.Serve(ln)
|
|
t.Cleanup(func() { ts.Close() })
|
|
|
|
conn := dialTarget(t, ln.Addr().String())
|
|
|
|
// Login should work (discovery is fine without volumes)
|
|
params := NewParams()
|
|
params.Set("InitiatorName", testInitiatorName)
|
|
params.Set("SessionType", "Discovery")
|
|
req := makeLoginReq(StageSecurityNeg, StageFullFeature, true, params)
|
|
req.SetCmdSN(1)
|
|
WritePDU(conn, req)
|
|
resp, _ := ReadPDU(conn)
|
|
if resp.LoginStatusClass() != LoginStatusSuccess {
|
|
t.Fatal("discovery login should succeed without volumes")
|
|
}
|
|
}
|
|
|
|
// testCloseRejectsLateConn verifies that handleConn returns immediately
|
|
// when ts.closed is already signaled. We call handleConn directly to
|
|
// deterministically exercise the closed-check guard.
|
|
func testCloseRejectsLateConn(t *testing.T) {
|
|
config := DefaultTargetConfig()
|
|
config.TargetName = testTargetName
|
|
logger := log.New(io.Discard, "", 0)
|
|
ts := NewTargetServer("127.0.0.1:0", config, logger)
|
|
ts.AddVolume(testTargetName, newMockDevice(256*4096))
|
|
|
|
// Close the target BEFORE passing a connection to handleConn.
|
|
// This simulates a conn accepted just before ln.Close() took effect.
|
|
close(ts.closed)
|
|
|
|
// Create a pipe to simulate a connection.
|
|
client, server := net.Pipe()
|
|
defer client.Close()
|
|
|
|
// Call handleConn directly. It must check ts.closed and return
|
|
// immediately without entering the session loop.
|
|
ts.sessions.Add(1)
|
|
done := make(chan struct{})
|
|
go func() {
|
|
ts.handleConn(server)
|
|
close(done)
|
|
}()
|
|
|
|
select {
|
|
case <-done:
|
|
// Good -- handleConn returned promptly.
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatal("handleConn did not return after ts.closed was signaled")
|
|
}
|
|
|
|
// The server-side conn should be closed by handleConn's defer.
|
|
// Verify by reading from client -- should get EOF.
|
|
buf := make([]byte, 1)
|
|
_, err := client.Read(buf)
|
|
if err == nil {
|
|
t.Fatal("expected EOF on client after handleConn returned")
|
|
}
|
|
}
|