diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Cluster/MeshTransportOptions.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Cluster/MeshTransportOptions.cs new file mode 100644 index 00000000..d3043e0e --- /dev/null +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Cluster/MeshTransportOptions.cs @@ -0,0 +1,61 @@ +namespace ZB.MOM.WW.OtOpcUa.Cluster; + +/// +/// Selects and configures the transport carrying central→node commands and node→central +/// deployment acks (per-cluster mesh Phase 2). +/// +/// +/// +/// is a deliberate dark switch, not a permanent knob: both +/// transports live in the tree for one phase so the docker-dev rig can run the same +/// deployment over each and compare, and so a bad Phase 2 rolls back with a config change +/// rather than a revert. Phase 6 deletes the DistributedPubSub branch along with the single +/// mesh that made it viable. +/// +/// +/// Discovery is deliberately asymmetric, matching the sister project. Central +/// discovers nodes from the database — enabled, non-maintenance ClusterNode +/// rows, rebuilt every . Nodes discover central from +/// this section: static, restart to change. The asymmetry follows the rate of change — +/// operators add and retire nodes, but central's own address is part of the deployment. +/// +/// +public sealed class MeshTransportOptions +{ + /// Configuration section name. + public const string SectionName = "MeshTransport"; + + /// Every command stays on DistributedPubSub — the pre-Phase-2 behaviour. + public const string ModeDps = "Dps"; + + /// Commands cross the ClusterClient receptionist boundary. + public const string ModeClusterClient = "ClusterClient"; + + /// + /// (default) or . Any other value fails + /// the host at startup rather than silently falling back — a transport that is neither of + /// these delivers nothing, and the symptom would be commands that vanish. + /// + public string Mode { get; set; } = ModeDps; + + /// + /// Akka remoting addresses of the central nodes, e.g. + /// akka.tcp://otopcua@central-1:4053. Node addresses only — the + /// /system/receptionist path is appended at construction time. + /// + /// + /// Each entry must be a central node's remoting endpoint, never this node's own port. + /// The sister project shipped a site template whose second contact was the site's own + /// remoting port; it is a permanent failure in the initial-contact rotation and stays + /// invisible until a command goes missing. + /// rejects the malformed shapes at boot instead. + /// + public string[] CentralContactPoints { get; set; } = []; + + /// + /// How often central re-reads ClusterNode rows to rebuild its contact points. + /// Refreshing on a timer (rather than only on admin change) means a row edited by any + /// process — migration, direct SQL, a second admin node — is picked up without a restart. + /// + public int ContactRefreshSeconds { get; set; } = 60; +} diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Cluster/MeshTransportOptionsValidator.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Cluster/MeshTransportOptionsValidator.cs new file mode 100644 index 00000000..afcd8779 --- /dev/null +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Cluster/MeshTransportOptionsValidator.cs @@ -0,0 +1,83 @@ +using Microsoft.Extensions.Options; + +namespace ZB.MOM.WW.OtOpcUa.Cluster; + +/// +/// Fails the host at startup on a shape that would leave +/// commands silently undelivered. +/// +/// +/// Every fault caught here otherwise surfaces as an absence — a deployment that never +/// arrives, an alarm acknowledgement that does nothing, a node whose ack is dropped without a +/// trace. An absence has no stack trace and no failing node to point at, which makes it the +/// most expensive class of misconfiguration to diagnose in the field. Refusing to start is +/// cheaper for everyone. +/// +public sealed class MeshTransportOptionsValidator : IValidateOptions +{ + /// + public ValidateOptionsResult Validate(string? name, MeshTransportOptions options) + { + ArgumentNullException.ThrowIfNull(options); + + var errors = new List(); + + var isDps = string.Equals(options.Mode, MeshTransportOptions.ModeDps, StringComparison.OrdinalIgnoreCase); + var isClusterClient = string.Equals( + options.Mode, MeshTransportOptions.ModeClusterClient, StringComparison.OrdinalIgnoreCase); + + if (!isDps && !isClusterClient) + { + errors.Add( + $"{MeshTransportOptions.SectionName}:{nameof(MeshTransportOptions.Mode)} is " + + $"'{options.Mode}'. Expected '{MeshTransportOptions.ModeDps}' or " + + $"'{MeshTransportOptions.ModeClusterClient}'."); + } + + if (options.ContactRefreshSeconds <= 0) + { + errors.Add( + $"{MeshTransportOptions.SectionName}:{nameof(MeshTransportOptions.ContactRefreshSeconds)} " + + $"must be greater than 0 (was {options.ContactRefreshSeconds}). Central rebuilds its " + + "ClusterClient contact points on this interval; a non-positive value would leave the " + + "contact set frozen at whatever it held when the node booted."); + } + + // Contact points are only load-bearing under ClusterClient mode. Requiring them under the + // default would make the section mandatory everywhere for a feature that is switched off — + // including on admin-only nodes, which have no reason to carry them at all. + if (isClusterClient) + { + if (options.CentralContactPoints.Length == 0) + { + errors.Add( + $"{MeshTransportOptions.SectionName}:{nameof(MeshTransportOptions.CentralContactPoints)} " + + "is empty. Under ClusterClient mode a driver node cannot reach central without at " + + "least one contact point, and its deployment acks would be dropped silently — the " + + "deployment would then time out naming a node that is running perfectly."); + } + + foreach (var contactPoint in options.CentralContactPoints) + { + if (!contactPoint.StartsWith("akka.tcp://", StringComparison.Ordinal)) + { + errors.Add( + $"Contact point '{contactPoint}' must start with 'akka.tcp://' — it is an Akka " + + "remoting address (akka.tcp://@:), not a bare host:port."); + } + else if (contactPoint.Contains("/user/", StringComparison.Ordinal) + || contactPoint.Contains("/system/", StringComparison.Ordinal)) + { + errors.Add( + $"Contact point '{contactPoint}' carries an actor-path suffix. Configure the " + + "node address only — '/system/receptionist' is appended at construction time, " + + "and a doubled suffix produces a path that never resolves."); + } + } + } + + return errors.Count == 0 + ? ValidateOptionsResult.Success + : ValidateOptionsResult.Fail(string.Join(" ", errors)); + } +} diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Cluster/ServiceCollectionExtensions.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Cluster/ServiceCollectionExtensions.cs index 7e5eaa45..1363191e 100644 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Cluster/ServiceCollectionExtensions.cs +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Cluster/ServiceCollectionExtensions.cs @@ -34,6 +34,12 @@ public static class ServiceCollectionExtensions services.AddValidatedOptions( configuration, AkkaClusterOptions.SectionName); + // Per-cluster mesh Phase 2: which transport carries central→node commands. Validated at + // startup for the same reason the cluster options are — a contact point that cannot resolve + // produces silence, not an error, and silence is indistinguishable from "nothing to do". + services.AddValidatedOptions( + configuration, MeshTransportOptions.SectionName); + services.AddSingleton(); return services; diff --git a/tests/Core/ZB.MOM.WW.OtOpcUa.Cluster.Tests/MeshTransportOptionsValidatorTests.cs b/tests/Core/ZB.MOM.WW.OtOpcUa.Cluster.Tests/MeshTransportOptionsValidatorTests.cs new file mode 100644 index 00000000..af627a9b --- /dev/null +++ b/tests/Core/ZB.MOM.WW.OtOpcUa.Cluster.Tests/MeshTransportOptionsValidatorTests.cs @@ -0,0 +1,138 @@ +using Microsoft.Extensions.Options; +using Shouldly; +using Xunit; + +namespace ZB.MOM.WW.OtOpcUa.Cluster.Tests; + +/// +/// Every failure this validator catches otherwise surfaces as an absence — a deployment +/// that never arrives, an alarm ack that does nothing, a node whose ack is dropped without a +/// trace. Absences are the hardest class of fault to attribute in the field, so the whole point +/// of these tests is that the host refuses to start instead. +/// +public class MeshTransportOptionsValidatorTests +{ + private static ValidateOptionsResult Validate(MeshTransportOptions o) => + new MeshTransportOptionsValidator().Validate(MeshTransportOptions.SectionName, o); + + [Fact] + public void Default_options_are_valid() + { + // The default is Dps — the pre-Phase-2 transport. A node that never sets this section must + // behave exactly as it did before, which is the whole premise of the dark switch. + Validate(new MeshTransportOptions()).Succeeded.ShouldBeTrue(); + } + + [Fact] + public void Unknown_mode_fails() + { + var result = Validate(new MeshTransportOptions { Mode = "grpc" }); + + result.Failed.ShouldBeTrue(); + result.FailureMessage.ShouldContain("grpc"); + } + + [Fact] + public void Mode_matching_is_case_insensitive() + { + Validate(new MeshTransportOptions + { + Mode = "clusterclient", + CentralContactPoints = ["akka.tcp://otopcua@central-1:4053"], + }).Succeeded.ShouldBeTrue(); + } + + [Fact] + public void Non_positive_contact_refresh_fails() + { + var result = Validate(new MeshTransportOptions { ContactRefreshSeconds = 0 }); + + result.Failed.ShouldBeTrue(); + result.FailureMessage.ShouldContain(nameof(MeshTransportOptions.ContactRefreshSeconds)); + } + + [Fact] + public void ClusterClient_mode_requires_central_contact_points() + { + var result = Validate(new MeshTransportOptions + { + Mode = MeshTransportOptions.ModeClusterClient, + CentralContactPoints = [], + }); + + result.Failed.ShouldBeTrue(); + result.FailureMessage.ShouldContain(nameof(MeshTransportOptions.CentralContactPoints)); + } + + [Fact] + public void Dps_mode_does_not_require_central_contact_points() + { + // An admin-only node has no reason to carry them, and requiring them under the default mode + // would make the section mandatory everywhere for a feature that is switched off. + Validate(new MeshTransportOptions + { + Mode = MeshTransportOptions.ModeDps, + CentralContactPoints = [], + }).Succeeded.ShouldBeTrue(); + } + + [Fact] + public void A_contact_point_that_is_not_an_akka_address_fails() + { + var result = Validate(new MeshTransportOptions + { + Mode = MeshTransportOptions.ModeClusterClient, + CentralContactPoints = ["central-1:4053"], + }); + + result.Failed.ShouldBeTrue(); + result.FailureMessage.ShouldContain("akka.tcp://"); + } + + [Fact] + public void A_contact_point_carrying_an_actor_path_suffix_fails() + { + // The sister project's shipped site template listed a full receptionist path, and separately + // listed the site's OWN remoting port as the second contact — "a permanent failure in the + // initial-contact rotation". Both are invisible until a command goes missing. + var result = Validate(new MeshTransportOptions + { + Mode = MeshTransportOptions.ModeClusterClient, + CentralContactPoints = ["akka.tcp://otopcua@central-1:4053/system/receptionist"], + }); + + result.Failed.ShouldBeTrue(); + result.FailureMessage.ShouldContain("/system/receptionist"); + } + + [Fact] + public void One_bad_contact_point_among_good_ones_still_fails() + { + var result = Validate(new MeshTransportOptions + { + Mode = MeshTransportOptions.ModeClusterClient, + CentralContactPoints = + [ + "akka.tcp://otopcua@central-1:4053", + "central-2:4053", + ], + }); + + result.Failed.ShouldBeTrue(); + result.FailureMessage.ShouldContain("central-2:4053"); + } + + [Fact] + public void Well_formed_ClusterClient_options_succeed() + { + Validate(new MeshTransportOptions + { + Mode = MeshTransportOptions.ModeClusterClient, + CentralContactPoints = + [ + "akka.tcp://otopcua@central-1:4053", + "akka.tcp://otopcua@central-2:4053", + ], + }).Succeeded.ShouldBeTrue(); + } +}