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
This commit is contained in:
Joseph Doherty
2026-07-22 10:39:37 -04:00
parent 1ec831883c
commit 5439f14804
4 changed files with 288 additions and 0 deletions
@@ -0,0 +1,61 @@
namespace ZB.MOM.WW.OtOpcUa.Cluster;
/// <summary>
/// Selects and configures the transport carrying central→node commands and node→central
/// deployment acks (per-cluster mesh Phase 2).
/// </summary>
/// <remarks>
/// <para>
/// <see cref="Mode"/> is a deliberate <b>dark switch</b>, 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.
/// </para>
/// <para>
/// <b>Discovery is deliberately asymmetric</b>, matching the sister project. Central
/// discovers nodes <i>from the database</i> — enabled, non-maintenance <c>ClusterNode</c>
/// rows, rebuilt every <see cref="ContactRefreshSeconds"/>. Nodes discover central <i>from
/// this section</i>: 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.
/// </para>
/// </remarks>
public sealed class MeshTransportOptions
{
/// <summary>Configuration section name.</summary>
public const string SectionName = "MeshTransport";
/// <summary>Every command stays on DistributedPubSub — the pre-Phase-2 behaviour.</summary>
public const string ModeDps = "Dps";
/// <summary>Commands cross the ClusterClient receptionist boundary.</summary>
public const string ModeClusterClient = "ClusterClient";
/// <summary>
/// <see cref="ModeDps"/> (default) or <see cref="ModeClusterClient"/>. 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.
/// </summary>
public string Mode { get; set; } = ModeDps;
/// <summary>
/// Akka remoting addresses of the central nodes, e.g.
/// <c>akka.tcp://otopcua@central-1:4053</c>. <b>Node addresses only</b> — the
/// <c>/system/receptionist</c> path is appended at construction time.
/// </summary>
/// <remarks>
/// Each entry must be a <i>central</i> 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. <see cref="MeshTransportOptionsValidator"/>
/// rejects the malformed shapes at boot instead.
/// </remarks>
public string[] CentralContactPoints { get; set; } = [];
/// <summary>
/// How often central re-reads <c>ClusterNode</c> 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.
/// </summary>
public int ContactRefreshSeconds { get; set; } = 60;
}
@@ -0,0 +1,83 @@
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));
}
}
@@ -34,6 +34,12 @@ public static class ServiceCollectionExtensions
services.AddValidatedOptions<AkkaClusterOptions, AkkaClusterOptionsValidator>(
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<MeshTransportOptions, MeshTransportOptionsValidator>(
configuration, MeshTransportOptions.SectionName);
services.AddSingleton<IClusterRoleInfo, ClusterRoleInfo>();
return services;
@@ -0,0 +1,138 @@
using Microsoft.Extensions.Options;
using Shouldly;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Cluster.Tests;
/// <summary>
/// Every failure this validator catches otherwise surfaces as an <i>absence</i> — 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.
/// </summary>
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();
}
}