Files
lmxopcua/tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests/Communication/MeshGrpcCommandDialerTests.cs
T
Joseph Doherty 48dc2120c6 fix(mesh): gate follows the endpoint not the mode + review fixes
The known-cluster gate published only under Mode=Grpc, but the
interceptor it feeds is registered on every admin node and 1B maps
CentralCommandService by LISTENER PORT — neither consults the mode. A
dark-switch rollback to ClusterClient or Dps would therefore have left
that endpoint serving with the gate permanently inactive, silently
restoring both the timing oracle and the pre-auth secret-store/audit
flood, as a side effect of a rollback nobody would connect to
authentication. The gate is central's cluster inventory, not transport
state, so it is now decoupled from the mode entirely:

- SetKnownClusters + InvalidateAll run on EVERY successful contact load
  in EVERY mode, and the contact-refresh timer starts in all three.
  Under Dps this is a ConfigDb query every ContactRefreshSeconds that
  did not run before; central always has a ConfigDb, and one indexed
  read a minute is the cheaper half of the trade.
- The GATE set is built from ALL ClusterNode rows. The eligibility
  filter moved out of the query's WHERE and into the loop, so the FAN
  set is byte-for-byte what it was (enabled && !maintenance) while the
  gate widens. This also dissolves the maintenance coupling flagged in
  1A.2: a cluster whose nodes are all disabled or in maintenance can
  still ack, and refusing it would make maintenance mode an
  authentication switch. Central still refuses clusters it has never
  heard of.
- HandleContactsLoaded keeps a documented order — targets adopted, gate
  published, cache dropped — now pinned by test.

Guards: PreStart mirrors the null-dialer error for a null PSK provider
(silent absence is the worst outcome there — nothing malfunctions, so
nothing is investigated, while the gate never publishes and keys never
rotate); a failed contact load now says out loud that the gate is stale
and, before any load has succeeded, inactive; MeshGrpcCommandDialer
clamps a non-positive DispatchTimeoutSeconds to 10 with a construction
Warning, because the validator for that key is driver-role-scoped and a
pure-admin central reads it unvalidated; the dialer's local PSK refusal
was shortened so its remediation survives the actor's 200-char
truncation of remote-authored status detail; the portless-node warn-once
set is rebuilt each refresh, so a node that regains then loses a port
warns again — the regression is the occurrence worth seeing.

Docs/pins: dialer disposal comment no longer claims one-way; the two
interceptor-test comments claiming Matches has a default implementation
corrected (it deliberately has none); new pins for per-refresh
invalidation, the SetKnownClusters-before-InvalidateAll order, and one
channel per endpoint via a new CachedChannelCount.

The_known_cluster_set_is_not_published_by_the_other_two_modes is
replaced — its premise is obsolete — by three facts: every mode
publishes, gate set is a strict superset of the fan set, and the handler
ordering.

ControlPlane.Tests 230 -> 236, Host.Tests 92, Cluster.Tests 184,
0 warnings.
2026-08-06 06:44:20 -04:00

339 lines
14 KiB
C#

using System.Diagnostics.CodeAnalysis;
using Grpc.Core;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Cluster;
using ZB.MOM.WW.OtOpcUa.Commons.Protos.MeshTransport.V1;
using ZB.MOM.WW.OtOpcUa.ControlPlane.Communication;
namespace ZB.MOM.WW.OtOpcUa.ControlPlane.Tests.Communication;
/// <summary>
/// ClusterClient→gRPC Phase 1A — central's outbound command dial. One node, one call, no retry.
/// </summary>
/// <remarks>
/// The facts that matter are the credential ones: the bearer must be the TARGET node's cluster's
/// key (not central's, and not some other cluster's), and a cluster with no resolvable key must
/// never be dialled at all. Everything else is plumbing.
/// </remarks>
public sealed class MeshGrpcCommandDialerTests
{
private const string Endpoint = "http://site-a-1:4056";
private static CommandEnvelope Envelope() => new()
{
CorrelationId = Guid.NewGuid().ToString("N"),
RestartDriver = new RestartDriverCommand
{
ClusterId = "SITE-A",
DriverInstanceId = "drv-1",
ActorByUserName = "operator",
CorrelationId = Guid.NewGuid().ToString("N"),
},
};
private static MeshGrpcCommandDialer CreateDialer(
IMeshPskProvider psk,
RecordingClient client,
int dispatchTimeoutSeconds = 10)
=> new(
psk,
Options.Create(new MeshTransportOptions { DispatchTimeoutSeconds = dispatchTimeoutSeconds }),
NullLogger<MeshGrpcCommandDialer>.Instance,
_ => client);
[Fact]
public async Task Dial_presents_the_target_clusters_key_and_names_that_cluster()
{
// THE assertion. Central holds N keys; sending the wrong one turns a legitimate command into
// a PermissionDenied, and sending a fleet-wide one would be the blast radius this design
// exists to avoid.
var client = new RecordingClient();
var dialer = CreateDialer(
new FakePskProvider(("SITE-A", "psk-site-a"), ("SITE-B", "psk-site-b")), client);
await dialer.DispatchAsync(Endpoint, Envelope(), "SITE-B", CancellationToken.None);
client.LastHeaders.ShouldNotBeNull();
client.LastHeaders!.GetValue("authorization").ShouldBe("Bearer psk-site-b");
client.LastHeaders.GetValue("x-otopcua-cluster").ShouldBe("SITE-B");
}
[Fact]
public async Task A_cluster_with_no_key_is_refused_locally_and_never_dialled()
{
// Fail-closed: not "dial and let the node reject it". An unauthenticated command attempt
// against a node's control surface should not exist even as a rejected one.
var client = new RecordingClient();
var dialer = CreateDialer(new FakePskProvider(("SITE-A", "psk-site-a")), client);
var ex = await Should.ThrowAsync<RpcException>(
() => dialer.DispatchAsync(Endpoint, Envelope(), "SITE-Z", CancellationToken.None));
ex.StatusCode.ShouldBe(StatusCode.Unauthenticated);
client.Calls.ShouldBe(0);
}
[Fact]
public async Task The_envelope_reaches_the_node_unchanged()
{
var client = new RecordingClient();
var dialer = CreateDialer(new FakePskProvider(("SITE-A", "psk-site-a")), client);
var envelope = Envelope();
await dialer.DispatchAsync(Endpoint, envelope, "SITE-A", CancellationToken.None);
client.LastRequest.ShouldBe(envelope);
}
[Fact]
public async Task A_node_rejection_surfaces_as_its_own_RpcException()
{
// The actor grades per-node outcomes off the status code, so it must arrive intact rather
// than wrapped in an AggregateException or swallowed here.
var client = new RecordingClient
{
Fault = new RpcException(new Status(StatusCode.PermissionDenied, "nope")),
};
var dialer = CreateDialer(new FakePskProvider(("SITE-A", "psk-site-a")), client);
var ex = await Should.ThrowAsync<RpcException>(
() => dialer.DispatchAsync(Endpoint, Envelope(), "SITE-A", CancellationToken.None));
ex.StatusCode.ShouldBe(StatusCode.PermissionDenied);
}
[Fact]
public async Task The_per_call_deadline_bounds_a_node_that_never_answers()
{
// A dispatch dials every node in the fleet. Without this bound one unreachable node holds a
// task and a socket for as long as the process lives, while the deploy path's own apply
// deadline expires around it.
var client = new RecordingClient { HangUntilCancelled = true };
var dialer = CreateDialer(
new FakePskProvider(("SITE-A", "psk-site-a")), client, dispatchTimeoutSeconds: 1);
await Should.ThrowAsync<OperationCanceledException>(
() => dialer.DispatchAsync(Endpoint, Envelope(), "SITE-A", CancellationToken.None)
.WaitAsync(TimeSpan.FromSeconds(10)));
}
[Fact]
public async Task The_callers_token_still_cancels_a_dial_inside_the_deadline()
{
// Linked, not replaced: actor shutdown must cancel everything in flight even when the
// per-call deadline is nowhere near expiring.
var client = new RecordingClient { HangUntilCancelled = true };
var dialer = CreateDialer(
new FakePskProvider(("SITE-A", "psk-site-a")), client, dispatchTimeoutSeconds: 600);
using var cts = new CancellationTokenSource();
var dial = dialer.DispatchAsync(Endpoint, Envelope(), "SITE-A", cts.Token);
await cts.CancelAsync();
await Should.ThrowAsync<OperationCanceledException>(() => dial.WaitAsync(TimeSpan.FromSeconds(10)));
}
[Fact]
public async Task A_non_positive_dispatch_timeout_is_clamped_rather_than_honoured()
{
// Zero would mean an UNBOUNDED call — the exact failure the deadline exists to prevent — and
// MeshTransportOptionsValidator's rule for this key is scoped to driver-role nodes, so a
// pure-admin central reaches this code with the value unvalidated. Clamp loudly.
var client = new RecordingClient { HangUntilCancelled = true };
var logger = new RecordingLogger();
var dialer = new MeshGrpcCommandDialer(
new FakePskProvider(("SITE-A", "psk-site-a")),
Options.Create(new MeshTransportOptions { DispatchTimeoutSeconds = 0 }),
logger,
_ => client);
logger.Warnings.ShouldContain(w => w.Contains("DispatchTimeoutSeconds"));
// And the clamp is real, not just logged: the dial must still expire on its own.
await Should.ThrowAsync<OperationCanceledException>(
() => dialer.DispatchAsync(Endpoint, Envelope(), "SITE-A", CancellationToken.None)
.WaitAsync(TimeSpan.FromSeconds(20)));
}
[Fact]
public async Task Dialing_after_disposal_throws_rather_than_repopulating_the_channel_cache()
{
var client = new RecordingClient();
var dialer = CreateDialer(new FakePskProvider(("SITE-A", "psk-site-a")), client);
dialer.Dispose();
await Should.ThrowAsync<ObjectDisposedException>(
() => dialer.DispatchAsync(Endpoint, Envelope(), "SITE-A", CancellationToken.None));
}
[Fact]
public async Task The_real_channel_factory_dials_and_is_disposable()
{
// The fake client seam covers every other fact here, which means nothing else ever exercises
// GrpcChannel.ForAddress or the cache it fills. Dial an endpoint nothing is listening on:
// the failure must be an ordinary RpcException the actor can log, not a construction throw,
// and Dispose must then clean up the channel the dial created.
var dialer = new MeshGrpcCommandDialer(
new FakePskProvider(("SITE-A", "psk-site-a")),
Options.Create(new MeshTransportOptions { DispatchTimeoutSeconds = 5 }),
NullLogger<MeshGrpcCommandDialer>.Instance);
await Should.ThrowAsync<RpcException>(
() => dialer.DispatchAsync(
"http://127.0.0.1:1", Envelope(), "SITE-A", CancellationToken.None));
Should.NotThrow(dialer.Dispose);
}
[Fact]
public async Task Two_dials_to_one_endpoint_share_a_single_channel()
{
// The cache is the whole defence against a socket per call: a deploy dispatch fans across
// every node in the fleet, and it repeats on every redeploy. A channel per dial exhausts
// ports on central long before anything else notices.
var dialer = new MeshGrpcCommandDialer(
new FakePskProvider(("SITE-A", "psk-site-a")),
Options.Create(new MeshTransportOptions { DispatchTimeoutSeconds = 5 }),
NullLogger<MeshGrpcCommandDialer>.Instance);
// Nothing is listening, so both dials fail — irrelevant here: the channel is constructed
// before the call is placed, which is exactly what is being counted.
for (var i = 0; i < 2; i++)
{
await Should.ThrowAsync<RpcException>(
() => dialer.DispatchAsync(
"http://127.0.0.1:1", Envelope(), "SITE-A", CancellationToken.None));
}
dialer.CachedChannelCount.ShouldBe(1);
// A second endpoint is a second channel — the cache keys on the endpoint, it does not
// collapse every node onto one connection.
await Should.ThrowAsync<RpcException>(
() => dialer.DispatchAsync(
"http://127.0.0.1:2", Envelope(), "SITE-A", CancellationToken.None));
dialer.CachedChannelCount.ShouldBe(2);
Should.NotThrow(dialer.Dispose);
}
[Fact]
public void It_activates_from_the_container_despite_the_optional_client_factory_parameter()
{
// The seam is an optional constructor parameter, which MS.DI honours only because it checks
// HasDefaultValue. Get that wrong — by making it non-optional, or by adding a second public
// constructor — and the failure is a startup crash on the admin node, invisible to every
// other test here because they all construct the dialer by hand.
var services = new ServiceCollection();
services.AddLogging();
services.AddSingleton<IMeshPskProvider>(new FakePskProvider(("SITE-A", "psk-site-a")));
services.AddSingleton<IOptions<MeshTransportOptions>>(
Options.Create(new MeshTransportOptions { DispatchTimeoutSeconds = 10 }));
services.AddSingleton<IMeshCommandDialer, MeshGrpcCommandDialer>();
using var provider = services.BuildServiceProvider();
provider.GetRequiredService<IMeshCommandDialer>().ShouldBeOfType<MeshGrpcCommandDialer>();
}
/// <summary>
/// A <see cref="NodeCommandService.NodeCommandServiceClient"/> that records what it was
/// handed and answers however the test asks. The generated client's protected parameterless
/// constructor exists precisely for this.
/// </summary>
private sealed class RecordingClient : NodeCommandService.NodeCommandServiceClient
{
/// <summary>Number of calls that reached the client.</summary>
public int Calls { get; private set; }
/// <summary>Headers of the most recent call.</summary>
public Metadata? LastHeaders { get; private set; }
/// <summary>Request of the most recent call.</summary>
public CommandEnvelope? LastRequest { get; private set; }
/// <summary>When set, the call faults with this instead of answering.</summary>
public RpcException? Fault { get; set; }
/// <summary>When true, the call never completes until its token is cancelled.</summary>
public bool HangUntilCancelled { get; set; }
public override AsyncUnaryCall<ExecuteAck> ExecuteAsync(
CommandEnvelope request,
Metadata? headers = null,
DateTime? deadline = null,
CancellationToken cancellationToken = default)
{
Calls++;
LastHeaders = headers;
LastRequest = request;
var response = Fault is not null
? Task.FromException<ExecuteAck>(Fault)
: HangUntilCancelled
? HangAsync(cancellationToken)
: Task.FromResult(new ExecuteAck());
return new AsyncUnaryCall<ExecuteAck>(
response,
Task.FromResult(new Metadata()),
() => Status.DefaultSuccess,
() => [],
() => { });
}
private static async Task<ExecuteAck> HangAsync(CancellationToken ct)
{
await Task.Delay(Timeout.InfiniteTimeSpan, ct).ConfigureAwait(false);
return new ExecuteAck();
}
}
/// <summary>Captures Warning-level messages so a construction-time check is assertable.</summary>
private sealed class RecordingLogger : ILogger<MeshGrpcCommandDialer>
{
/// <summary>Every warning logged, formatted.</summary>
public List<string> Warnings { get; } = [];
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
public bool IsEnabled(LogLevel logLevel) => true;
public void Log<TState>(
LogLevel logLevel,
EventId eventId,
TState state,
Exception? exception,
Func<TState, Exception?, string> formatter)
{
if (logLevel == LogLevel.Warning)
Warnings.Add(formatter(state, exception));
}
}
/// <summary>An <see cref="IMeshPskProvider"/> over a fixed key set; unknown clusters fail closed.</summary>
private sealed class FakePskProvider(params (string Cluster, string Psk)[] keys) : IMeshPskProvider
{
private readonly Dictionary<string, string> _keys =
keys.ToDictionary(k => k.Cluster, k => k.Psk, StringComparer.OrdinalIgnoreCase);
public bool TryGetPsk(string clusterId, [NotNullWhen(true)] out string? psk)
=> _keys.TryGetValue(clusterId, out psk);
public bool Matches(string clusterId, string presented)
=> _keys.TryGetValue(clusterId, out var expected)
&& string.Equals(expected, presented, StringComparison.Ordinal);
public void SetKnownClusters(IReadOnlyCollection<string> clusterIds) { }
public void Invalidate(string clusterId) { }
public void InvalidateAll() { }
}
}