test: Phase 20 Tier 1 component tests — wiring proof for CI/CD

6 new component tests closing gaps identified in the test matrix audit:

P20-T4-C3: Missing projection with active V2 core fails closed
  - v2Core != nil, no projection cached → gate with "missing_engine_projection"

P20-T4-C6: Gate actually removes iSCSI target (enforcement)
  - real TargetServer → HasTarget(iqn)==true before gate
  - gate → HasTarget(iqn)==false (DisconnectVolume called)
  - ungate → HasTarget(iqn)==true (AddVolume restores)

P20-T5-C3: FailoverDiagnosticSnapshot carries both mode fields
  - register volume with EngineProjectionMode + ClusterReplicationMode
  - trigger pending rebuild → volume appears in diagnostic
  - diagnostic entry carries both modes from registry lookup

P20-T3-C5: V2PromotionMode diagnostic tri-state
  - disabled / placeholder_fail_closed / transport_ready
  - all three configurations produce correct diagnostic value

P20-T1-C3: EngineProjectionMode proto round-trip
  - set value survives InfoMessageToProto → InfoMessageFromProto
  - empty value produces nil proto field (presence semantics)

P20-T4-C8: ActivationGated proto round-trip
  - gated=true + reason survives round-trip
  - not-gated produces no spurious reason

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
pingqiu
2026-04-05 19:39:00 -07:00
co-authored by Claude Opus 4.6
parent 1c7154a11a
commit 6bf9a6c283
3 changed files with 200 additions and 0 deletions
@@ -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)
}
}
@@ -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")
}
}
@@ -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)
}
}