namespace ZB.MOM.WW.OtOpcUa.ControlPlane.Fleet; /// What kind of disagreement was found between a node's real address and its row. public enum AddressMismatchKind { /// /// The row was matched to a live member by NodeId, but its Host / AkkaPort /// columns name a different address. Phase 2 dials those columns, so central would dial /// somewhere the node is not — and the symptom there is a silent absence of acks. /// RowDialTargetDisagrees, /// /// A driver-role member is in the cluster with no enabled ClusterNode row at its /// address. Its deployment acks are discarded (see ConfigPublishCoordinator), so it /// silently receives deployments it is never credited for. /// RunningNodeHasNoRow, /// /// An enabled row names a node that is not currently a driver-role member. Either it is down /// — in which case the next deployment fails at the apply deadline — or it binds an address /// other than the one its row claims. /// EnabledRowNotInCluster, } /// One disagreement, with enough detail to act on without opening the DB. /// Which disagreement this is. /// The host:port identity the finding is about. /// Human-readable specifics, including what to do about it. public sealed record ClusterNodeAddressMismatch(AddressMismatchKind Kind, string NodeId, string Detail); /// A ClusterNode row reduced to the address facts this check cares about. /// The row's primary key, in the host:port space. /// The host central will dial in Phase 2. /// The remoting port central will dial in Phase 2. public sealed record ClusterNodeAddress(string NodeId, string Host, int AkkaPort); /// /// Compares the addresses driver nodes actually occupy against the ClusterNode rows that /// claim to describe them. /// /// /// /// Why this exists. ClusterNode.AkkaPort and the node's own Cluster:Port /// are the same fact stored in two places, and nothing makes them agree. A node that binds /// 4054 while its row says 4053 is unreachable from central once Phase 2 dials the row /// instead of gossiping — and an unenforced duplicated address is the exact shape of the /// Modbus/ModbusTcp and TwinCat/Focas drifts already recorded in /// this repo. Since Phase 1 also made the rows the deploy path's expected-ack set, a drifted /// row already costs a failed deployment today. /// /// /// It runs on an admin node, comparing rows against the membership an admin node can /// already see, rather than having each driver node assert its own row — Phase 4 removes the /// driver nodes' ConfigDb connection entirely, so a self-assertion written there would have /// to be deleted again. /// /// /// Scoped to the admin node's own cluster (Phase 6). Once the fleet splits into one /// mesh per cluster, an admin node no longer sees site members at all, and a fleet-wide sweep /// would report every other cluster's row as /// forever. The actor therefore scopes /// both inputs to this function to the admin node's own cluster: the rows/enabled /// set to rows whose ClusterId matches /// , and the /// observed membership to members carrying its own /// /// (FilterOwnClusterDriverMembers). Scoping only the rows would swap the /// false positive for a worse /// Error-level during the mixed-mesh /// window, where the DB scope is live but central still sees foreign members via gossip. A /// legacy admin node carrying no cluster role (null ClusterId/ClusterRole) still /// sees every member via gossip and reconciles the full fleet unchanged. The pure function /// below is untouched — scoping happens entirely in the actor's DB queries and its /// membership filter. /// /// public static class ClusterNodeAddressReconciler { /// /// Reconciles observed driver-role member addresses against the configured rows. /// /// /// host/port of every Up member carrying the driver role. Admin-only /// members must be excluded — they legitimately have no ClusterNode row, and including /// them would report a mismatch for correct configuration. /// /// Every ClusterNode row, enabled or not. /// /// The subset of that should currently be in the cluster: enabled and /// not in maintenance. Excluded rows are deliberately still matched against membership (a /// disabled row for a running node is worth knowing) but never reported as /// — being absent is the whole point /// of disabling one or putting it in maintenance. /// /// Every disagreement found, ordered by node id for stable logging. public static IReadOnlyList Reconcile( IReadOnlyCollection<(string Host, int Port)> observedDriverMembers, IReadOnlyCollection rows, IReadOnlyCollection expectedPresentNodeIds) { var byNodeId = new Dictionary(StringComparer.OrdinalIgnoreCase); foreach (var row in rows) byNodeId[row.NodeId] = row; var expectedPresent = new HashSet(expectedPresentNodeIds, StringComparer.OrdinalIgnoreCase); var findings = new List(); var seen = new HashSet(StringComparer.OrdinalIgnoreCase); foreach (var (host, port) in observedDriverMembers) { var nodeId = $"{host}:{port}"; seen.Add(nodeId); if (!byNodeId.TryGetValue(nodeId, out var row)) { findings.Add(new ClusterNodeAddressMismatch( AddressMismatchKind.RunningNodeHasNoRow, nodeId, $"driver node is in the cluster at {nodeId} but no ClusterNode row has that NodeId; " + "its deployment acks are discarded. Either register the row or correct the node's " + "Cluster:PublicHostname / Cluster:Port to match an existing one")); continue; } if (!string.Equals(row.Host, host, StringComparison.OrdinalIgnoreCase) || row.AkkaPort != port) { findings.Add(new ClusterNodeAddressMismatch( AddressMismatchKind.RowDialTargetDisagrees, nodeId, $"node is at {nodeId} but its row's dial target is {row.Host}:{row.AkkaPort}; " + "central will dial the wrong address once Phase 2 stops using gossip. " + "Correct ClusterNode.Host / ClusterNode.AkkaPort")); } } foreach (var row in rows) { if (seen.Contains(row.NodeId) || !expectedPresent.Contains(row.NodeId)) continue; findings.Add(new ClusterNodeAddressMismatch( AddressMismatchKind.EnabledRowNotInCluster, row.NodeId, $"enabled ClusterNode row {row.NodeId} has no matching driver member; the next " + "deployment will fail at the apply deadline waiting for it. Start the node, or set " + "ClusterNode.MaintenanceMode = 1 while it is down for maintenance")); } return findings .OrderBy(f => f.NodeId, StringComparer.OrdinalIgnoreCase) .ThenBy(f => f.Kind) .ToList(); } }