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();
}
}