Files
lmxopcua/src/Core/ZB.MOM.WW.OtOpcUa.Cluster/MeshTransportOptionsValidator.cs
T
Joseph Doherty 5439f14804 feat(mesh): MeshTransportOptions — dark switch between DPS and ClusterClient
Phase 2 Task 0. Binds a validated `MeshTransport` section selecting which
transport carries central->node commands, defaulting to Dps so nothing changes
until the rig gate passes.

The validator exists because every fault it catches otherwise surfaces as an
ABSENCE -- a deployment that never arrives, an alarm ack that does nothing --
which has no stack trace and no failing node to point at. Two of the shapes it
rejects are ported from the sister project's shipped mistakes: a contact point
carrying an actor-path suffix, and (documented in the options XML) a template
listing the node's OWN remoting port as a central contact, which is a permanent
failure in the initial-contact rotation.

Contact points are required only under ClusterClient mode; requiring them under
the default would make the section mandatory on admin-only nodes that have no
reason to carry them.

Sabotage-verified: relaxing the empty-contacts guard and the actor-path-suffix
guard turns exactly those two tests red, and nothing else.

Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-22 10:39:37 -04:00

84 lines
4.0 KiB
C#

using Microsoft.Extensions.Options;
namespace ZB.MOM.WW.OtOpcUa.Cluster;
/// <summary>
/// Fails the host at startup on a <see cref="MeshTransportOptions"/> shape that would leave
/// commands silently undelivered.
/// </summary>
/// <remarks>
/// Every fault caught here otherwise surfaces as an <i>absence</i> — 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.
/// </remarks>
public sealed class MeshTransportOptionsValidator : IValidateOptions<MeshTransportOptions>
{
/// <inheritdoc />
public ValidateOptionsResult Validate(string? name, MeshTransportOptions options)
{
ArgumentNullException.ThrowIfNull(options);
var errors = new List<string>();
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://<system>@<host>:<port>), 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));
}
}