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:
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user