diff --git a/weed/server/master_block_cluster_mode_test.go b/weed/server/master_block_cluster_mode_test.go index 1f3d5b9b0..78f52cf95 100644 --- a/weed/server/master_block_cluster_mode_test.go +++ b/weed/server/master_block_cluster_mode_test.go @@ -315,3 +315,73 @@ func TestT5_APISurface_DistinctNaming(t *testing.T) { info.EngineProjectionMode, info.ClusterReplicationMode) } } + +// P20-T5-C3: FailoverDiagnosticSnapshot carries both mode fields. +func TestT5_DiagnosticSnapshot_CarriesModes(t *testing.T) { + ms := testMasterServerForFailover(t) + registerVolumeWithReplica(t, ms, "vol-diag-modes", "vs1", "vs2", 1, 5*time.Second) + + // Set EngineProjectionMode + ClusterReplicationMode on the entry. + if err := ms.blockRegistry.UpdateEntry("vol-diag-modes", func(e *BlockVolumeEntry) { + e.EngineProjectionMode = "publish_healthy" + e.HasEngineProjectionMode = true + e.ClusterReplicationMode = "catching_up" + }); err != nil { + t.Fatalf("update entry: %v", err) + } + + // Trigger a pending rebuild so the volume appears in failover diagnostic. + ms.recordPendingRebuild("vs-dead", pendingRebuild{ + VolumeName: "vol-diag-modes", + NewPrimary: "vs2", + Epoch: 2, + }) + + diag := ms.FailoverDiagnosticSnapshot() + var found *FailoverVolumeState + for i := range diag.Volumes { + if diag.Volumes[i].VolumeName == "vol-diag-modes" { + found = &diag.Volumes[i] + break + } + } + if found == nil { + t.Fatal("expected vol-diag-modes in failover diagnostic") + } + if found.ClusterReplicationMode != "catching_up" { + t.Fatalf("diagnostic ClusterReplicationMode=%q, want catching_up", found.ClusterReplicationMode) + } + if found.EngineProjectionMode != "publish_healthy" { + t.Fatalf("diagnostic EngineProjectionMode=%q, want publish_healthy", found.EngineProjectionMode) + } +} + +// P20-T3-C5: V2PromotionMode diagnostic reflects all three states. +func TestT3_V2PromotionMode_DiagnosticTriState(t *testing.T) { + // State 1: disabled + ms1 := testMasterServerForFailover(t) + ms1.blockV2Promotion = false + diag1 := ms1.FailoverDiagnosticSnapshot() + if diag1.V2PromotionMode != "disabled" { + t.Fatalf("state 1: V2PromotionMode=%q, want disabled", diag1.V2PromotionMode) + } + + // State 2: placeholder_fail_closed + ms2 := testMasterServerForFailover(t) + ms2.blockV2Promotion = true + ms2.blockVSQueryEvidence = ms2.defaultBlockVSQueryEvidence + ms2.blockV2EvidenceTransport = false + diag2 := ms2.FailoverDiagnosticSnapshot() + if diag2.V2PromotionMode != "placeholder_fail_closed" { + t.Fatalf("state 2: V2PromotionMode=%q, want placeholder_fail_closed", diag2.V2PromotionMode) + } + + // State 3: transport_ready + ms3 := testMasterServerForFailover(t) + ms3.blockV2Promotion = true + ms3.blockV2EvidenceTransport = true + diag3 := ms3.FailoverDiagnosticSnapshot() + if diag3.V2PromotionMode != "transport_ready" { + t.Fatalf("state 3: V2PromotionMode=%q, want transport_ready", diag3.V2PromotionMode) + } +} diff --git a/weed/server/volume_grpc_block_activation_gate_test.go b/weed/server/volume_grpc_block_activation_gate_test.go index cd9626843..213f6ac2c 100644 --- a/weed/server/volume_grpc_block_activation_gate_test.go +++ b/weed/server/volume_grpc_block_activation_gate_test.go @@ -1,10 +1,13 @@ package weed_server import ( + "log" + "os" "testing" engine "github.com/seaweedfs/seaweedfs/sw-block/engine/replication" "github.com/seaweedfs/seaweedfs/weed/storage/blockvol" + "github.com/seaweedfs/seaweedfs/weed/storage/blockvol/iscsi" ) func TestT4_DegradedProjection_GatesActivation(t *testing.T) { @@ -179,3 +182,74 @@ func TestT4_ApplyCoreAssignment_GatesDegradedPrimary(t *testing.T) { t.Fatal("expected non-empty gate reason") } } + +// P20-T4-C3: Missing projection with active V2 core fails closed. +func TestT4_MissingProjection_FailsClosed(t *testing.T) { + bs := newTestBlockServiceDirect(t) + path := createTestVolDirect(t, bs, "gate-missing-proj") + + // v2Core is non-nil (production config), but no projection cached. + // This must fail closed, not silently leave serving enabled. + if bs.v2Core == nil { + t.Fatal("test requires v2Core != nil") + } + + bs.evaluateActivationGate(path) + + gated, reason := bs.IsActivationGated(path) + if !gated { + t.Fatal("expected activation gated when V2 core is active but projection missing") + } + if reason != "missing_engine_projection" { + t.Fatalf("reason=%q, want %q", reason, "missing_engine_projection") + } +} + +// P20-T4-C6: Gate actually removes iSCSI target (enforcement, not bookkeeping). +func TestT4_GateRemovesISCSITarget(t *testing.T) { + bs := newTestBlockServiceDirect(t) + path := createTestVolDirect(t, bs, "gate-iscsi-remove") + + // Create a real TargetServer (no listen, just registry). + logger := log.New(os.Stderr, "iscsi-test: ", log.LstdFlags) + ts := iscsi.NewTargetServer("127.0.0.1:0", iscsi.DefaultTargetConfig(), logger) + bs.targetServer = ts + + // Register volume with target. + vol, ok := bs.blockStore.GetBlockVolume(path) + if !ok { + t.Fatal("volume not found") + } + name := volumeNameFromPath(path) + iqn := bs.iqnPrefix + blockvol.SanitizeIQN(name) + adapter := blockvol.NewBlockVolAdapter(vol) + ts.AddVolume(iqn, adapter) + + if !ts.HasTarget(iqn) { + t.Fatal("target should exist before gate") + } + + // Inject degraded projection and gate. + bs.coreProjMu.Lock() + bs.coreProj[path] = engine.PublicationProjection{ + Mode: engine.ModeView{Name: engine.ModeDegraded, Reason: "test"}, + } + bs.coreProjMu.Unlock() + bs.evaluateActivationGate(path) + + if ts.HasTarget(iqn) { + t.Fatal("target should be removed after gate (enforcement, not just bookkeeping)") + } + + // Inject healthy projection and ungate. + bs.coreProjMu.Lock() + bs.coreProj[path] = engine.PublicationProjection{ + Mode: engine.ModeView{Name: engine.ModePublishHealthy}, + } + bs.coreProjMu.Unlock() + bs.evaluateActivationGate(path) + + if !ts.HasTarget(iqn) { + t.Fatal("target should be restored after ungate") + } +} diff --git a/weed/storage/blockvol/block_heartbeat_proto_test.go b/weed/storage/blockvol/block_heartbeat_proto_test.go index de7656232..cfa0c639d 100644 --- a/weed/storage/blockvol/block_heartbeat_proto_test.go +++ b/weed/storage/blockvol/block_heartbeat_proto_test.go @@ -623,3 +623,59 @@ func TestP10P1_ProtoRoundTrip_MissingServerID_NotSynthesized(t *testing.T) { t.Fatalf("decode: ReplicaServerID=%q, want empty (not synthesized)", decoded.ReplicaServerID) } } + +// P20-T1-C3: EngineProjectionMode survives proto round-trip. +func TestP20_ProtoRoundTrip_EngineProjectionMode(t *testing.T) { + orig := BlockVolumeInfoMessage{ + Path: "/data/epm-roundtrip.blk", + Epoch: 5, + EngineProjectionMode: "publish_healthy", + } + pb := InfoMessageToProto(orig) + decoded := InfoMessageFromProto(pb) + if decoded.EngineProjectionMode != "publish_healthy" { + t.Fatalf("EngineProjectionMode=%q after round-trip, want %q", decoded.EngineProjectionMode, "publish_healthy") + } + + // Empty field stays empty (nil presence). + origEmpty := BlockVolumeInfoMessage{Path: "/data/epm-empty.blk"} + pbEmpty := InfoMessageToProto(origEmpty) + if pbEmpty.EngineProjectionMode != nil { + t.Fatalf("nil EngineProjectionMode produced non-nil proto: %v", *pbEmpty.EngineProjectionMode) + } + decodedEmpty := InfoMessageFromProto(pbEmpty) + if decodedEmpty.EngineProjectionMode != "" { + t.Fatalf("empty EngineProjectionMode=%q after round-trip, want empty", decodedEmpty.EngineProjectionMode) + } +} + +// P20-T4-C8: ActivationGated + ActivationGateReason survive proto round-trip. +func TestP20_ProtoRoundTrip_ActivationGated(t *testing.T) { + orig := BlockVolumeInfoMessage{ + Path: "/data/gate-roundtrip.blk", + ActivationGated: true, + ActivationGateReason: "engine_projection_mode=degraded: barrier_timeout", + } + pb := InfoMessageToProto(orig) + decoded := InfoMessageFromProto(pb) + if !decoded.ActivationGated { + t.Fatal("ActivationGated=false after round-trip, want true") + } + if decoded.ActivationGateReason != orig.ActivationGateReason { + t.Fatalf("ActivationGateReason=%q, want %q", decoded.ActivationGateReason, orig.ActivationGateReason) + } + + // Not-gated: false should round-trip without spurious reason. + origNotGated := BlockVolumeInfoMessage{ + Path: "/data/gate-notgated.blk", + ActivationGated: false, + } + pbNot := InfoMessageToProto(origNotGated) + decodedNot := InfoMessageFromProto(pbNot) + if decodedNot.ActivationGated { + t.Fatal("not-gated produced ActivationGated=true after round-trip") + } + if decodedNot.ActivationGateReason != "" { + t.Fatalf("not-gated produced reason=%q, want empty", decodedNot.ActivationGateReason) + } +}