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>(
|
services.AddValidatedOptions<AkkaClusterOptions, AkkaClusterOptionsValidator>(
|
||||||
configuration, AkkaClusterOptions.SectionName);
|
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>();
|
services.AddSingleton<IClusterRoleInfo, ClusterRoleInfo>();
|
||||||
|
|
||||||
return services;
|
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