feat(mesh): NodeCommandService on the telemetry listener

This commit is contained in:
Joseph Doherty
2026-08-06 05:14:12 -04:00
parent 3f96115cab
commit 64774652b7
5 changed files with 310 additions and 1 deletions
@@ -0,0 +1,110 @@
using Akka.Actor;
using Akka.Hosting;
using Grpc.Core;
using Microsoft.Extensions.Logging;
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Mesh;
using ZB.MOM.WW.OtOpcUa.Commons.Protos.MeshTransport.V1;
using ZB.MOM.WW.OtOpcUa.Runtime;
// The generated service container is itself named NodeCommandService; alias its nested server base so
// this impl can keep the natural name without colliding with the generated type.
using GeneratedServiceBase =
ZB.MOM.WW.OtOpcUa.Commons.Protos.MeshTransport.V1.NodeCommandService.NodeCommandServiceBase;
// Akka.Actor (imported for IActorRef.Tell) declares a Status of its own; only the gRPC one is used here.
using Status = Grpc.Core.Status;
namespace ZB.MOM.WW.OtOpcUa.Host.Grpc;
/// <summary>
/// Node-side command ingress (ClusterClient→gRPC migration Phase 1). Central dials a driver node
/// and calls <see cref="Execute"/>; the envelope is decoded back into the same CLR record the
/// ClusterClient receptionist used to deliver and handed to this node's
/// <c>NodeCommunicationActor</c>, which re-emits it on the node's <c>EventStream</c> exactly as
/// before. Nothing downstream of that actor can tell which transport carried the command.
/// </summary>
/// <remarks>
/// <para>
/// <b>One-way, verbatim from the transport it replaces.</b> <c>ExecuteAck</c> means "decoded and
/// handed to the comm actor" — not applied, and not acknowledged by any EventStream subscriber.
/// The <c>Tell</c> is deliberately not an <c>Ask</c>: no migrated command has a reply, and
/// waiting for one would invent a semantic central does not have.
/// </para>
/// <para>
/// <b>Decode runs BEFORE the registry lookup, and the two failures are graded differently.</b>
/// An undecodable envelope is <see cref="StatusCode.InvalidArgument"/> — permanent, and it will
/// never decode on a retry. A call arriving before <c>AddOtOpcUaRuntime</c> has spawned and
/// registered the comm actor is <see cref="StatusCode.Unavailable"/> — the house readiness
/// convention, transient, and correct to retry. Checking readiness first would report a
/// permanently malformed command as a readiness problem and invite an unbounded retry loop.
/// </para>
/// <para>
/// <b>Every log line carries the envelope's own <c>correlation_id</c>, read as a raw string.</b>
/// Three of the four commands also carry a domain correlation, but <c>AlarmCommand</c> has no
/// CLR member for one, so the envelope field is the only id every command shares — and it is
/// still readable when the inner fields are what failed to parse, which is precisely when an
/// operator needs it.
/// </para>
/// <para>
/// Inbound auth is <c>MeshCommandAuthInterceptor</c>, scoped to this service's method prefix and
/// registered at the <c>AddGrpc</c> level; it fail-closes with no <c>MeshTransport:GrpcPsk</c>
/// configured. Mapping this service is what makes that gate live.
/// </para>
/// </remarks>
public sealed class NodeCommandGrpcService : GeneratedServiceBase
{
private readonly ActorRegistry _registry;
private readonly ILogger<NodeCommandGrpcService> _log;
/// <summary>Initializes a new instance of the <see cref="NodeCommandGrpcService"/> class.</summary>
/// <param name="registry">Actor registry holding this node's <c>NodeCommunicationActor</c>.</param>
/// <param name="log">Logger.</param>
public NodeCommandGrpcService(ActorRegistry registry, ILogger<NodeCommandGrpcService> log)
{
_registry = registry;
_log = log;
}
/// <inheritdoc />
public override Task<ExecuteAck> Execute(CommandEnvelope request, ServerCallContext context)
{
ArgumentNullException.ThrowIfNull(request);
ArgumentNullException.ThrowIfNull(context);
object command;
try
{
command = MeshTransportDtoMapper.FromEnvelope(request);
}
catch (Exception ex) when (ex is InvalidOperationException or FormatException or ArgumentException)
{
// The envelope carries no credential material, so logging its correlation id is safe and is
// the only handle an operator has on a call whose inner fields did not parse.
_log.LogWarning(
ex,
"Rejected a mesh command (correlationId={CorrelationId}, oneof={CommandCase}): the "
+ "envelope could not be decoded. The command was NOT delivered.",
request.CorrelationId, request.CommandCase);
throw new RpcException(new Status(
StatusCode.InvalidArgument, "Command envelope could not be decoded."));
}
if (!_registry.TryGet<NodeCommunicationActorKey>(out var comm))
{
_log.LogWarning(
"Rejected a mesh command (correlationId={CorrelationId}, type={CommandType}): this node's "
+ "communication actor is not registered yet. The node is still starting; the command was "
+ "NOT delivered and central may retry.",
request.CorrelationId, command.GetType().Name);
throw new RpcException(new Status(
StatusCode.Unavailable, "Node is still starting: the command endpoint is not ready."));
}
comm.Tell(command);
_log.LogDebug(
"Mesh: accepted {CommandType} (correlationId={CorrelationId}) over gRPC; handed to the node "
+ "communication actor.",
command.GetType().Name, request.CorrelationId);
return Task.FromResult(new ExecuteAck());
}
}
@@ -740,9 +740,18 @@ if (hasAdmin && configServeGrpcPort > 0)
// Driver-node live-telemetry stream (per-cluster mesh Phase 5). Central dials in; gated by
// TelemetryStreamAuthInterceptor (fail-closed on empty Telemetry:ApiKey).
//
// The node-side command endpoint (ClusterClient→gRPC migration Phase 1) rides the SAME driver-role
// h2c listener rather than claiming a fourth port: central already dials this node here, both
// directions are per-cluster-keyed, and a separate port would be one more thing to open on every
// site firewall for no isolation gain. Mapping it is what makes MeshCommandAuthInterceptor live —
// it is registered above and is a pass-through until a call matches this service's method prefix.
// It fail-closes with no MeshTransport:GrpcPsk, so an operator who maps the service without setting
// a key gets a closed endpoint, not an open one.
if (hasDriver && telemetryListenPort > 0)
{
app.MapGrpcService<TelemetryStreamGrpcService>();
app.MapGrpcService<NodeCommandGrpcService>();
}
app.MapOtOpcUaHealth();