using ZB.MOM.WW.OtOpcUa.Configuration.Entities; using ZB.MOM.WW.OtOpcUa.Configuration.Enums; namespace ZB.MOM.WW.OtOpcUa.Server.Redundancy; /// /// Pure-function mapper from the shared config DB's + /// rows to an immutable . /// Validates Phase 6.3 Stream A.1 invariants and throws /// on violation so the coordinator can fail startup /// fast with a clear message rather than boot into an ambiguous state. /// /// /// Stateless — the caller owns the DB round-trip + hands rows in. Keeping it pure makes /// the invariant matrix testable without EF or SQL Server. /// public static class ClusterTopologyLoader { /// Build a topology snapshot for the given self node. Throws on invariant violation. public static RedundancyTopology Load(string selfNodeId, ServerCluster cluster, IReadOnlyList nodes) { ArgumentException.ThrowIfNullOrWhiteSpace(selfNodeId); ArgumentNullException.ThrowIfNull(cluster); ArgumentNullException.ThrowIfNull(nodes); ValidateClusterShape(cluster, nodes); ValidateUniqueApplicationUris(nodes); ValidatePrimaryCount(cluster, nodes); var self = nodes.FirstOrDefault(n => string.Equals(n.NodeId, selfNodeId, StringComparison.OrdinalIgnoreCase)) ?? throw new InvalidTopologyException( $"Self node '{selfNodeId}' is not a member of cluster '{cluster.ClusterId}'. " + $"Members: {string.Join(", ", nodes.Select(n => n.NodeId))}."); var peers = nodes .Where(n => !string.Equals(n.NodeId, selfNodeId, StringComparison.OrdinalIgnoreCase)) .Select(n => new RedundancyPeer( NodeId: n.NodeId, Role: n.RedundancyRole, Host: n.Host, OpcUaPort: n.OpcUaPort, DashboardPort: n.DashboardPort, ApplicationUri: n.ApplicationUri)) .ToList(); return new RedundancyTopology( ClusterId: cluster.ClusterId, SelfNodeId: self.NodeId, SelfRole: self.RedundancyRole, Mode: cluster.RedundancyMode, Peers: peers, SelfApplicationUri: self.ApplicationUri); } private static void ValidateClusterShape(ServerCluster cluster, IReadOnlyList nodes) { if (nodes.Count == 0) throw new InvalidTopologyException($"Cluster '{cluster.ClusterId}' has zero nodes."); // Decision #83 — v2.0 caps clusters at two nodes. if (nodes.Count > 2) throw new InvalidTopologyException( $"Cluster '{cluster.ClusterId}' has {nodes.Count} nodes. v2.0 supports at most 2 nodes per cluster (decision #83)."); // Every node must belong to the given cluster. var wrongCluster = nodes.FirstOrDefault(n => !string.Equals(n.ClusterId, cluster.ClusterId, StringComparison.OrdinalIgnoreCase)); if (wrongCluster is not null) throw new InvalidTopologyException( $"Node '{wrongCluster.NodeId}' belongs to cluster '{wrongCluster.ClusterId}', not '{cluster.ClusterId}'."); } private static void ValidateUniqueApplicationUris(IReadOnlyList nodes) { var dup = nodes .GroupBy(n => n.ApplicationUri, StringComparer.Ordinal) .FirstOrDefault(g => g.Count() > 1); if (dup is not null) throw new InvalidTopologyException( $"Nodes {string.Join(", ", dup.Select(n => n.NodeId))} share ApplicationUri '{dup.Key}'. " + $"OPC UA Part 4 requires unique ApplicationUri per server — clients pin trust here (decision #86)."); } private static void ValidatePrimaryCount(ServerCluster cluster, IReadOnlyList nodes) { // Standalone mode: any role is fine. Warm / Hot: at most one Primary per cluster. if (cluster.RedundancyMode == RedundancyMode.None) return; var primaries = nodes.Count(n => n.RedundancyRole == RedundancyRole.Primary); if (primaries > 1) throw new InvalidTopologyException( $"Cluster '{cluster.ClusterId}' has {primaries} Primary nodes in redundancy mode {cluster.RedundancyMode}. " + $"At most one Primary per cluster (decision #84). Runtime detects and demotes both to ServiceLevel 2 " + $"per the 8-state matrix; startup fails fast to surface the misconfiguration earlier."); } }