6a86201105
MeshGrpcCommandDialer places one NodeCommandService.Execute call on one node: a GrpcChannel cached per endpoint (the GrpcDeploymentArtifactFetcher shape), the TARGET node's cluster's bearer plus x-otopcua-cluster naming that cluster, and a per-call deadline linked to the caller's token. A cluster with no resolvable key is refused locally with Unauthenticated — there is no arm on which a command leaves central unauthenticated, even one the far side would have rejected. CentralCommunicationActor gains the third mode branch. The contact load now also projects GrpcPort and, from the SAME rows in the SAME pass, builds the dispatch set — which is what makes 'the gRPC fan reaches exactly the SendToAll recipient set' structural rather than two queries that have to keep agreeing. A row with a null GrpcPort is skipped warn-once (the TelemetryNodeSource precedent). Per-node results are piped back for LOGGING ONLY: named node, gRPC status, dropped. No retry, no queue. Every dial is scheduled on the thread pool rather than called from the receive. Found by review and pinned red-first: an async method runs synchronously to its first await, and the dialer's pre-await work is TryGetPsk, which blocks on the secret store — bounded but seconds, and repeated after every refresh since the refresh drops the cache. Dialing inline parked the actor's mailbox, so deployment acks queued behind a slow node; the regression test tells an ApplyAck while a dial is held and fails without the hop. The contact refresh also wires the Phase 1A register: it publishes the loaded cluster ids as the PSK provider's known-cluster set and drops the key cache, so a rotated key takes effect within one ContactRefreshSeconds. Neither happens under Dps or ClusterClient. Both existing mode branches are behaviourally untouched and every existing fact passes unmodified. The one edit to shared text: the Status.Failure warning drops the word 'ClusterClient', since that handler now serves all three transports. ControlPlane.Tests 205 -> 230, Host.Tests 92, Cluster.Tests 184, 0 warnings.
272 lines
11 KiB
C#
272 lines
11 KiB
C#
using System.Diagnostics.CodeAnalysis;
|
|
using Grpc.Core;
|
|
using Microsoft.Extensions.DependencyInjection;
|
|
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 }),
|
|
_ => 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_zero_dispatch_timeout_leaves_the_dial_bounded_only_by_the_callers_token()
|
|
{
|
|
// The validator requires > 0 under Mode=Grpc, so this is the misconfigured/other-mode shape.
|
|
// It must not be read as "expire immediately" — that would drop every command instantly.
|
|
var client = new RecordingClient();
|
|
var dialer = CreateDialer(
|
|
new FakePskProvider(("SITE-A", "psk-site-a")), client, dispatchTimeoutSeconds: 0);
|
|
|
|
await dialer.DispatchAsync(Endpoint, Envelope(), "SITE-A", CancellationToken.None);
|
|
|
|
client.Calls.ShouldBe(1);
|
|
}
|
|
|
|
[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 }));
|
|
|
|
await Should.ThrowAsync<RpcException>(
|
|
() => dialer.DispatchAsync(
|
|
"http://127.0.0.1:1", Envelope(), "SITE-A", CancellationToken.None));
|
|
|
|
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.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>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() { }
|
|
}
|
|
}
|