425 lines
20 KiB
C#
425 lines
20 KiB
C#
using Grpc.Core;
|
|
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.Messages.Deploy;
|
|
using ZB.MOM.WW.OtOpcUa.Commons.Protos.MeshTransport.V1;
|
|
using ZB.MOM.WW.OtOpcUa.Commons.Types;
|
|
using ZB.MOM.WW.OtOpcUa.Runtime.Communication;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Communication;
|
|
|
|
/// <summary>
|
|
/// The node-side gRPC ack sender (ClusterClient→gRPC migration Task 1B.2). The client is a fake,
|
|
/// so what is under test is the sender's own logic: the ordered single-pass over
|
|
/// <c>CentralGrpcEndpoints</c>, the per-call credentials, and the drop that used to be silent.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// <b>The load-bearing fact is that ONE endpoint is dialled on success.</b> This replaces an
|
|
/// Akka <c>ClusterClient.Send</c>, which is single-target at-most-once — not a
|
|
/// <c>SendToAll</c>. Both central nodes route an ack to the same
|
|
/// <c>ConfigPublishCoordinator</c> singleton proxy, whose NodeId-keyed upsert absorbs a
|
|
/// duplicate idempotently — so the single-endpoint rule is defence in depth and a bound
|
|
/// on wasted dials, not what protects the seal.
|
|
/// </para>
|
|
/// <para>
|
|
/// The failure side matters just as much: the receptionist dropped an undeliverable ack in
|
|
/// silence, and the whole point of the migration is that the same drop is now a Warning naming
|
|
/// every endpoint and the status it answered with.
|
|
/// </para>
|
|
/// </remarks>
|
|
public sealed class GrpcApplyAckSenderTests
|
|
{
|
|
private const string Psk = "psk-that-must-never-be-logged";
|
|
|
|
private static ApplyAck Ack() => new(
|
|
new DeploymentId(Guid.NewGuid()),
|
|
new NodeId("site-a-1:4053"),
|
|
ApplyAckOutcome.Applied,
|
|
null,
|
|
new CorrelationId(Guid.NewGuid()));
|
|
|
|
private static GrpcApplyAckSender Sender(
|
|
string[] endpoints,
|
|
Func<string, CentralCommandService.CentralCommandServiceClient> factory,
|
|
ILogger<GrpcApplyAckSender>? log = null,
|
|
string[]? roles = null)
|
|
=> new(
|
|
Options.Create(new MeshTransportOptions
|
|
{
|
|
Mode = MeshTransportOptions.ModeGrpc,
|
|
CentralGrpcEndpoints = endpoints,
|
|
GrpcPsk = Psk,
|
|
AckTimeoutSeconds = 7,
|
|
}),
|
|
Options.Create(new AkkaClusterOptions { Roles = roles ?? ["driver", "cluster-SITE-A"] }),
|
|
log ?? NullLogger<GrpcApplyAckSender>.Instance,
|
|
factory);
|
|
|
|
[Fact]
|
|
public async Task A_successful_first_endpoint_is_the_ONLY_one_dialled()
|
|
{
|
|
// The single-count semantic. Two endpoints are configured for availability, not for fan-out:
|
|
// an ack posted to both would reach the one coordinator singleton twice.
|
|
var consulted = new List<string>();
|
|
var calls = new List<(ApplyAckMessage Request, CallOptions Options)>();
|
|
var factory = (string endpoint) =>
|
|
{
|
|
consulted.Add(endpoint);
|
|
return (CentralCommandService.CentralCommandServiceClient)new RecordingClient(calls);
|
|
};
|
|
|
|
await Sender(["http://central-1:4055", "http://central-2:4055"], factory).SendAsync(Ack());
|
|
|
|
consulted.ShouldBe(["http://central-1:4055"]);
|
|
calls.Count.ShouldBe(1);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task The_wire_message_carries_the_whole_ack()
|
|
{
|
|
var calls = new List<(ApplyAckMessage Request, CallOptions Options)>();
|
|
var ack = new ApplyAck(
|
|
new DeploymentId(Guid.NewGuid()),
|
|
new NodeId("site-a-2:4053"),
|
|
ApplyAckOutcome.Failed,
|
|
"disk full",
|
|
new CorrelationId(Guid.NewGuid()));
|
|
|
|
await Sender(["http://central-1:4055"], _ => new RecordingClient(calls)).SendAsync(ack);
|
|
|
|
var sent = calls.Single().Request;
|
|
sent.DeploymentId.ShouldBe(ack.DeploymentId.ToString());
|
|
sent.NodeId.ShouldBe("site-a-2:4053");
|
|
sent.Outcome.ShouldBe(ApplyOutcome.Failed);
|
|
sent.FailureReason.ShouldBe("disk full");
|
|
sent.CorrelationId.ShouldBe(ack.CorrelationId.ToString());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Every_call_carries_the_bearer_the_cluster_header_and_a_deadline()
|
|
{
|
|
// Central's CentralCommandAuthInterceptor grades the bearer against the key of the cluster
|
|
// named in x-otopcua-cluster, so an ack missing either header is PermissionDenied — and the
|
|
// deadline is what stops a dead first endpoint from stalling the ack forever.
|
|
var calls = new List<(ApplyAckMessage Request, CallOptions Options)>();
|
|
var before = DateTime.UtcNow;
|
|
|
|
await Sender(["http://central-1:4055"], _ => new RecordingClient(calls)).SendAsync(Ack());
|
|
|
|
var options = calls.Single().Options;
|
|
options.Headers.ShouldNotBeNull();
|
|
options.Headers.GetValue("authorization").ShouldBe($"Bearer {Psk}");
|
|
options.Headers.GetValue("x-otopcua-cluster").ShouldBe("SITE-A");
|
|
options.Deadline.ShouldNotBeNull();
|
|
// Bounded from BOTH sides against the configured 7s. A lower bound of `before` alone would
|
|
// accept any positive timeout at all — the value could be ignored entirely and the fact would
|
|
// still pass.
|
|
options.Deadline!.Value.ShouldBeGreaterThan(before.AddSeconds(6));
|
|
options.Deadline!.Value.ShouldBeLessThanOrEqualTo(before.AddSeconds(8));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Each_endpoint_gets_a_FRESH_deadline()
|
|
{
|
|
// The failure this pins is silent and is the characteristic WAN one: a central that HANGS
|
|
// rather than refusing. With one absolute deadline hoisted over the whole walk, endpoint 1
|
|
// burns all of AckTimeoutSeconds, and endpoint 2 is then dialled with a deadline already in
|
|
// the past — it fails instantly as DeadlineExceeded without a packet leaving the node, so the
|
|
// surviving central is never really tried and failover is defeated while still LOOKING like it
|
|
// ran. Both stamps come from real UtcNow, so a delay on the failing path plus a strict `>` is
|
|
// the whole test.
|
|
var calls = new List<(ApplyAckMessage Request, CallOptions Options)>();
|
|
var factory = (string endpoint) => endpoint.Contains("central-1", StringComparison.Ordinal)
|
|
? (CentralCommandService.CentralCommandServiceClient)new SlowThrowingClient(calls, TimeSpan.FromMilliseconds(50))
|
|
: new RecordingClient(calls);
|
|
|
|
await Sender(["http://central-1:4055", "http://central-2:4055"], factory).SendAsync(Ack());
|
|
|
|
calls.Count.ShouldBe(2);
|
|
calls[1].Options.Deadline!.Value.ShouldBeGreaterThan(calls[0].Options.Deadline!.Value);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task An_unavailable_first_endpoint_fails_over_to_the_second()
|
|
{
|
|
var consulted = new List<string>();
|
|
var calls = new List<(ApplyAckMessage Request, CallOptions Options)>();
|
|
var factory = (string endpoint) =>
|
|
{
|
|
consulted.Add(endpoint);
|
|
return endpoint.Contains("central-1", StringComparison.Ordinal)
|
|
? (CentralCommandService.CentralCommandServiceClient)
|
|
new ThrowingClient(new RpcException(new Status(StatusCode.Unavailable, "down")))
|
|
: new RecordingClient(calls);
|
|
};
|
|
|
|
await Sender(["http://central-1:4055", "http://central-2:4055"], factory).SendAsync(Ack());
|
|
|
|
consulted.ShouldBe(["http://central-1:4055", "http://central-2:4055"]);
|
|
calls.Count.ShouldBe(1);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task When_every_endpoint_fails_the_ack_is_dropped_with_one_naming_Warning()
|
|
{
|
|
// The pre-migration behaviour was a silent drop. It is still a drop — there is no queue and no
|
|
// retry — but the operator now gets the node, the deployment, the correlation id and what each
|
|
// endpoint actually answered, which is everything needed to tell "central is down" from
|
|
// "central rejected this node's key".
|
|
var log = new CapturingLogger<GrpcApplyAckSender>();
|
|
var attempts = 0;
|
|
var factory = (string endpoint) =>
|
|
{
|
|
attempts++;
|
|
return (CentralCommandService.CentralCommandServiceClient)new ThrowingClient(
|
|
new RpcException(new Status(
|
|
endpoint.Contains("central-1", StringComparison.Ordinal)
|
|
? StatusCode.Unavailable
|
|
: StatusCode.PermissionDenied,
|
|
"no")));
|
|
};
|
|
var ack = Ack();
|
|
|
|
await Sender(["http://central-1:4055", "http://central-2:4055"], factory, log).SendAsync(ack);
|
|
|
|
attempts.ShouldBe(2);
|
|
var warning = log.Entries.Single(e => e.Level == LogLevel.Warning);
|
|
warning.Message.ShouldContain(ack.NodeId.Value!);
|
|
warning.Message.ShouldContain(ack.DeploymentId.ToString());
|
|
warning.Message.ShouldContain(ack.CorrelationId.ToString());
|
|
warning.Message.ShouldContain("Unavailable");
|
|
warning.Message.ShouldContain("PermissionDenied");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task A_failed_pass_never_posts_the_ack_twice()
|
|
{
|
|
// The counterpart of the first fact: failover walks forward only. A retry of an
|
|
// already-delivered endpoint could double-count this node at the coordinator, which is the one
|
|
// outcome worse than the ack being lost.
|
|
var calls = new List<(ApplyAckMessage Request, CallOptions Options)>();
|
|
var consulted = new List<string>();
|
|
var factory = (string endpoint) =>
|
|
{
|
|
consulted.Add(endpoint);
|
|
return endpoint.Contains("c1", StringComparison.Ordinal)
|
|
? (CentralCommandService.CentralCommandServiceClient)
|
|
new ThrowingClient(new RpcException(new Status(StatusCode.Unavailable, "down")))
|
|
: new RecordingClient(calls);
|
|
};
|
|
|
|
await Sender(["http://c1:4055", "http://c2:4055", "http://c3:4055"], factory).SendAsync(Ack());
|
|
|
|
// c3 is never dialled: the pass stops at the first success, and it does not go back to c1.
|
|
consulted.ShouldBe(["http://c1:4055", "http://c2:4055"]);
|
|
calls.Count.ShouldBe(1);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task The_pre_shared_key_is_never_written_to_a_log()
|
|
{
|
|
// The bearer travels in the call headers on every attempt; the failure log names endpoints and
|
|
// status codes only. A per-endpoint dump of the Metadata would put the cluster's PSK in the
|
|
// node log of every deployment that could not reach central.
|
|
var log = new CapturingLogger<GrpcApplyAckSender>();
|
|
var factory = (string _) => (CentralCommandService.CentralCommandServiceClient)
|
|
new ThrowingClient(new RpcException(new Status(StatusCode.Unavailable, "down")));
|
|
|
|
await Sender(["http://central-1:4055"], factory, log).SendAsync(Ack());
|
|
|
|
log.Entries.ShouldNotBeEmpty();
|
|
log.Entries.Any(e => e.Message.Contains(Psk, StringComparison.Ordinal)).ShouldBeFalse();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task An_empty_endpoint_list_is_a_logged_drop_not_a_throw()
|
|
{
|
|
// The validator refuses to start a Grpc driver node with no endpoints, so this is reachable
|
|
// only through a code path that built the sender for a node the validator never graded (a test
|
|
// harness, an admin-only graph). It must still be a visible drop rather than an exception on a
|
|
// task the actor discards, which would vanish entirely.
|
|
var log = new CapturingLogger<GrpcApplyAckSender>();
|
|
var dialled = false;
|
|
var factory = (string _) =>
|
|
{
|
|
dialled = true;
|
|
return (CentralCommandService.CentralCommandServiceClient)new RecordingClient([]);
|
|
};
|
|
|
|
await Sender([], factory, log).SendAsync(Ack());
|
|
|
|
dialled.ShouldBeFalse();
|
|
log.Entries.Single(e => e.Level == LogLevel.Warning).Message
|
|
.ShouldContain("CentralGrpcEndpoints");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task A_node_with_no_cluster_role_still_posts_and_is_warned_about()
|
|
{
|
|
// No cluster-scoped role means no x-otopcua-cluster header, and central has no key to grade the
|
|
// bearer against — every ack will come back PermissionDenied. Posting anyway (rather than
|
|
// refusing locally) keeps the rejection where it can be read on both sides, and the local
|
|
// Warning explains a PermissionDenied that would otherwise look like a key mismatch.
|
|
var log = new CapturingLogger<GrpcApplyAckSender>();
|
|
var calls = new List<(ApplyAckMessage Request, CallOptions Options)>();
|
|
|
|
await Sender(["http://central-1:4055"], _ => new RecordingClient(calls), log, roles: ["driver"])
|
|
.SendAsync(Ack());
|
|
|
|
calls.Single().Options.Headers!.Any(h =>
|
|
h.Key.Equals("x-otopcua-cluster", StringComparison.Ordinal)).ShouldBeFalse();
|
|
log.Entries.Any(e => e.Level == LogLevel.Warning
|
|
&& e.Message.Contains("cluster-", StringComparison.Ordinal)).ShouldBeTrue();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task An_unexpected_client_fault_is_a_logged_drop_not_an_unobserved_task_fault()
|
|
{
|
|
// The caller is an actor that discards the returned Task, so anything escaping here would be
|
|
// swallowed by the runtime and rediscovered as an unobserved exception at GC — the exact
|
|
// silent drop this transport exists to remove.
|
|
var log = new CapturingLogger<GrpcApplyAckSender>();
|
|
var factory = (string _) => (CentralCommandService.CentralCommandServiceClient)
|
|
new FaultingClient(new InvalidOperationException("channel disposed"));
|
|
|
|
await Should.NotThrowAsync(() => Sender(["http://central-1:4055"], factory, log).SendAsync(Ack()));
|
|
|
|
log.Entries.Any(e => e.Level == LogLevel.Warning).ShouldBeTrue();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Dispose_releases_the_real_channel_cache_and_is_idempotent()
|
|
{
|
|
// Built with NO client factory, so the production DefaultClientFactory runs and a real
|
|
// GrpcChannel lands in the cache — otherwise Dispose is walking an empty dictionary and this
|
|
// test proves nothing about the thing it names. The address is loopback port 1: nothing
|
|
// listens, the connection is refused immediately, and no traffic leaves the machine.
|
|
var log = new CapturingLogger<GrpcApplyAckSender>();
|
|
var sender = new GrpcApplyAckSender(
|
|
Options.Create(new MeshTransportOptions
|
|
{
|
|
Mode = MeshTransportOptions.ModeGrpc,
|
|
CentralGrpcEndpoints = ["http://127.0.0.1:1"],
|
|
GrpcPsk = Psk,
|
|
AckTimeoutSeconds = 5,
|
|
}),
|
|
Options.Create(new AkkaClusterOptions { Roles = ["driver", "cluster-SITE-A"] }),
|
|
log);
|
|
|
|
await sender.SendAsync(Ack());
|
|
|
|
// The post must have failed (nothing is listening) — which is what put a channel in the cache.
|
|
log.Entries.Any(e => e.Level == LogLevel.Warning).ShouldBeTrue();
|
|
|
|
sender.Dispose();
|
|
Should.NotThrow(sender.Dispose);
|
|
}
|
|
|
|
// ---- fakes --------------------------------------------------------------------------------
|
|
|
|
private static AsyncUnaryCall<ReportAck> Ok() => new(
|
|
Task.FromResult(new ReportAck()),
|
|
Task.FromResult(new Metadata()),
|
|
() => Status.DefaultSuccess,
|
|
() => [],
|
|
() => { });
|
|
|
|
/// <summary>A generated client double recording the request and the call options it was given.</summary>
|
|
private sealed class RecordingClient(List<(ApplyAckMessage Request, CallOptions Options)> calls)
|
|
: CentralCommandService.CentralCommandServiceClient
|
|
{
|
|
public override AsyncUnaryCall<ReportAck> ReportApplyAsync(ApplyAckMessage request, CallOptions options)
|
|
{
|
|
calls.Add((request, options));
|
|
return Ok();
|
|
}
|
|
}
|
|
|
|
/// <summary>A generated client double whose call throws — an endpoint that is down or refuses.</summary>
|
|
private sealed class ThrowingClient(RpcException toThrow) : CentralCommandService.CentralCommandServiceClient
|
|
{
|
|
public override AsyncUnaryCall<ReportAck> ReportApplyAsync(ApplyAckMessage request, CallOptions options)
|
|
=> throw toThrow;
|
|
}
|
|
|
|
/// <summary>
|
|
/// A generated client double modelling a HUNG endpoint: it accepts the call, records it, then
|
|
/// takes real wall-clock time before failing — the shape a shared deadline cannot survive.
|
|
/// </summary>
|
|
private sealed class SlowThrowingClient(
|
|
List<(ApplyAckMessage Request, CallOptions Options)> calls, TimeSpan delay)
|
|
: CentralCommandService.CentralCommandServiceClient
|
|
{
|
|
public override AsyncUnaryCall<ReportAck> ReportApplyAsync(ApplyAckMessage request, CallOptions options)
|
|
{
|
|
calls.Add((request, options));
|
|
return new AsyncUnaryCall<ReportAck>(
|
|
HangThenFail(delay),
|
|
Task.FromResult(new Metadata()),
|
|
() => new Status(StatusCode.DeadlineExceeded, "hung"),
|
|
() => [],
|
|
() => { });
|
|
}
|
|
|
|
private static async Task<ReportAck> HangThenFail(TimeSpan delay)
|
|
{
|
|
await Task.Delay(delay);
|
|
throw new RpcException(new Status(StatusCode.DeadlineExceeded, "hung"));
|
|
}
|
|
}
|
|
|
|
/// <summary>A generated client double throwing something that is NOT an <see cref="RpcException"/>.</summary>
|
|
private sealed class FaultingClient(Exception toThrow) : CentralCommandService.CentralCommandServiceClient
|
|
{
|
|
public override AsyncUnaryCall<ReportAck> ReportApplyAsync(ApplyAckMessage request, CallOptions options)
|
|
=> throw toThrow;
|
|
}
|
|
|
|
/// <summary>Test logger that captures all log entries.</summary>
|
|
/// <typeparam name="T">The category type.</typeparam>
|
|
private sealed class CapturingLogger<T> : ILogger<T>
|
|
{
|
|
/// <summary>Gets the captured log entries.</summary>
|
|
public List<(LogLevel Level, string Message, Exception? Exception)> Entries { get; } = [];
|
|
|
|
/// <summary>Begins a scope (stub implementation).</summary>
|
|
/// <typeparam name="TState">The type of the scope state.</typeparam>
|
|
/// <param name="state">The scope state.</param>
|
|
/// <returns>A no-op scope.</returns>
|
|
public IDisposable BeginScope<TState>(TState state) where TState : notnull => NullScope.Instance;
|
|
|
|
/// <summary>Checks if logging is enabled (always true).</summary>
|
|
/// <param name="logLevel">The log level to check.</param>
|
|
/// <returns><c>true</c>.</returns>
|
|
public bool IsEnabled(LogLevel logLevel) => true;
|
|
|
|
/// <summary>Logs an entry and captures it.</summary>
|
|
/// <typeparam name="TState">The type of the log state.</typeparam>
|
|
/// <param name="logLevel">The log level.</param>
|
|
/// <param name="eventId">The event ID.</param>
|
|
/// <param name="state">The log state.</param>
|
|
/// <param name="exception">The exception, if any.</param>
|
|
/// <param name="formatter">The log message formatter.</param>
|
|
public void Log<TState>(
|
|
LogLevel logLevel, EventId eventId, TState state, Exception? exception,
|
|
Func<TState, Exception?, string> formatter)
|
|
{
|
|
Entries.Add((logLevel, formatter(state, exception), exception));
|
|
}
|
|
|
|
private sealed class NullScope : IDisposable
|
|
{
|
|
/// <summary>Gets the singleton instance.</summary>
|
|
public static NullScope Instance { get; } = new();
|
|
|
|
/// <summary>No-op.</summary>
|
|
public void Dispose() { }
|
|
}
|
|
}
|
|
}
|