using System.Collections.Immutable; using System.Reflection; using Akka.Actor; using Akka.Cluster; using Akka.Util; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.ControlPlane.Fleet; namespace ZB.MOM.WW.OtOpcUa.ControlPlane.Tests; /// /// Per-cluster mesh Phase 1 Task 4: ClusterNode.AkkaPort and the node's own /// Cluster:Port are the same fact in two places, and nothing makes them agree. These pin /// the shapes that drift takes. /// public sealed class ClusterNodeAddressReconcilerTests { private static ClusterNodeAddress Row(string host, int akkaPort, string? nodeId = null) => new(nodeId ?? $"{host}:{akkaPort}", host, akkaPort); /// A fleet whose rows match membership reports nothing. [Fact] public void Matching_rows_and_membership_produce_no_findings() { var findings = ClusterNodeAddressReconciler.Reconcile( [("site-a-1", 4053), ("site-a-2", 4053)], [Row("site-a-1", 4053), Row("site-a-2", 4053)], ["site-a-1:4053", "site-a-2:4053"]); findings.ShouldBeEmpty(); } /// /// The drift the plan names: a node binds 4054 while its row says 4053. Membership shows it /// at an address no row claims, and the row it should have matched looks absent — both halves /// are reported, because either alone reads as a different problem. /// [Fact] public void Node_bound_to_the_wrong_port_is_reported_from_both_sides() { var findings = ClusterNodeAddressReconciler.Reconcile( [("site-a-1", 4054)], [Row("site-a-1", 4053)], ["site-a-1:4053"]); findings.Count.ShouldBe(2); findings.ShouldContain(f => f.Kind == AddressMismatchKind.RunningNodeHasNoRow && f.NodeId == "site-a-1:4054"); findings.ShouldContain(f => f.Kind == AddressMismatchKind.EnabledRowNotInCluster && f.NodeId == "site-a-1:4053"); } /// /// The Phase 2 shape: the row's NodeId still matches membership, but its Host / /// AkkaPort columns — the values central will actually dial — have been edited away /// from it. Gossip still works today, so nothing else would notice. /// [Fact] public void Row_whose_dial_target_disagrees_with_its_own_NodeId_is_reported() { var findings = ClusterNodeAddressReconciler.Reconcile( [("site-a-1", 4053)], [Row("site-a-1", 4054, nodeId: "site-a-1:4053")], ["site-a-1:4053"]); var only = findings.ShouldHaveSingleItem(); only.Kind.ShouldBe(AddressMismatchKind.RowDialTargetDisagrees); only.NodeId.ShouldBe("site-a-1:4053"); only.Detail.ShouldContain("site-a-1:4054"); } /// A running driver node nobody registered — the coordinator discards its acks. [Fact] public void Running_node_with_no_row_is_reported() { var findings = ClusterNodeAddressReconciler.Reconcile( [("ghost-9", 4053)], [], []); findings.ShouldHaveSingleItem().Kind.ShouldBe(AddressMismatchKind.RunningNodeHasNoRow); } /// /// A disabled row for an absent node is silence, not a finding — being absent is the entire /// point of disabling it. Without this the maintenance hatch would trade a failed deploy for /// a permanent warning. /// [Fact] public void Disabled_row_for_an_absent_node_is_not_reported() { var findings = ClusterNodeAddressReconciler.Reconcile( [("site-a-1", 4053)], [Row("site-a-1", 4053), Row("site-a-2", 4053)], ["site-a-1:4053"]); findings.ShouldBeEmpty(); } /// /// An admin-only member has no ClusterNode row by design, so the caller filters /// membership to driver-role members. Passing an unfiltered list would report correct /// configuration as broken — pinned here because the filter lives at the call site. /// [Fact] public void Admin_only_members_are_the_callers_responsibility_to_exclude() { // What the actor passes: driver members only. ClusterNodeAddressReconciler.Reconcile( [("central-1", 4053)], [Row("central-1", 4053)], ["central-1:4053"]) .ShouldBeEmpty(); // What an unfiltered call would produce — the false positive this guards against. ClusterNodeAddressReconciler.Reconcile( [("central-1", 4053), ("admin-only-1", 4053)], [Row("central-1", 4053)], ["central-1:4053"]) .ShouldHaveSingleItem().Kind.ShouldBe(AddressMismatchKind.RunningNodeHasNoRow); } /// /// Host matching is case-insensitive, matching the coordinator's NodeIdComparer — DNS /// and SQL collation are both case-insensitive, so the same node surfaces with either casing. /// [Fact] public void Host_matching_is_case_insensitive() { ClusterNodeAddressReconciler.Reconcile( [("SITE-A-1", 4053)], [Row("site-a-1", 4053)], ["site-a-1:4053"]) .ShouldBeEmpty(); } private static int _memberUid; // Akka's Member.Create factories are internal, so reflect the Up-status overload // Create(UniqueAddress, int upNumber, MemberStatus, ImmutableHashSet roles, AppVersion). private static readonly MethodInfo MemberCreate = typeof(Member).GetMethod( "Create", BindingFlags.Static | BindingFlags.NonPublic | BindingFlags.Public, binder: null, types: [ typeof(UniqueAddress), typeof(int), typeof(MemberStatus), typeof(ImmutableHashSet), typeof(AppVersion), ], modifiers: null) ?? throw new InvalidOperationException("Akka.Cluster.Member.Create(5-arg) not found — Akka API changed"); /// /// Builds an Up cluster at : /// carrying , for exercising /// directly. /// private static Member UpMember(string host, int port, params string[] roles) => (Member)MemberCreate.Invoke(null, [ new UniqueAddress(new Address("akka.tcp", "otopcua", host, port), Interlocked.Increment(ref _memberUid)), 1, MemberStatus.Up, roles.ToImmutableHashSet(), AppVersion.Zero, ])!; /// /// Per-cluster mesh Phase 6: scoped to cluster-MAIN, a driver member carrying a FOREIGN /// cluster role (cluster-SITE-A) — the mixed-mesh window, where central still sees it via /// gossip while its row is already filtered out by ClusterId — is excluded from observed. Left /// in, it would hit the Error-level branch. /// [Fact] public void Foreign_cluster_role_member_is_excluded_when_scoped() { var observed = ClusterNodeAddressReconcilerActor.FilterOwnClusterDriverMembers( [UpMember("site-a-1", 4053, "driver", "cluster-SITE-A")], ownClusterRole: "cluster-MAIN"); observed.ShouldBeEmpty(); } /// Scoped to cluster-MAIN, a member carrying that same role is kept. [Fact] public void Own_cluster_role_member_is_kept_when_scoped() { var observed = ClusterNodeAddressReconcilerActor.FilterOwnClusterDriverMembers( [UpMember("main-1", 4053, "driver", "cluster-MAIN")], ownClusterRole: "cluster-MAIN"); observed.ShouldHaveSingleItem().ShouldBe(("main-1", 4053)); } /// /// Legacy: a null cluster role keeps every Up driver member regardless of cluster — an admin /// node with no cluster role genuinely sees the whole fleet via gossip (reconcile-all). /// [Fact] public void Null_cluster_role_keeps_all_driver_members() { var observed = ClusterNodeAddressReconcilerActor.FilterOwnClusterDriverMembers( [ UpMember("main-1", 4053, "driver", "cluster-MAIN"), UpMember("site-a-1", 4053, "driver", "cluster-SITE-A"), ], ownClusterRole: null); observed.Count.ShouldBe(2); observed.ShouldContain(("main-1", 4053)); observed.ShouldContain(("site-a-1", 4053)); } /// Non-driver members (e.g. admin-only) are excluded whether or not a scope is set. [Fact] public void Non_driver_members_are_always_excluded() { ClusterNodeAddressReconcilerActor.FilterOwnClusterDriverMembers( [UpMember("central-1", 4053, "admin", "cluster-MAIN")], ownClusterRole: "cluster-MAIN") .ShouldBeEmpty(); } }