feat(mesh): CentralCommandService on the ConfigServe listener

This commit is contained in:
Joseph Doherty
2026-08-06 05:21:17 -04:00
parent a400b41502
commit 7e7df337cc
5 changed files with 518 additions and 5 deletions
@@ -0,0 +1,118 @@
using Akka.Actor;
using Akka.Hosting;
using Grpc.Core;
using Microsoft.Extensions.Logging;
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Deploy;
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Mesh;
using ZB.MOM.WW.OtOpcUa.Commons.Protos.MeshTransport.V1;
using ZB.MOM.WW.OtOpcUa.ControlPlane;
// The generated service container is itself named CentralCommandService; 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.CentralCommandService.CentralCommandServiceBase;
// Akka.Actor (needed for the single-argument Tell) also defines a Status. Every Status in this file
// is the gRPC one, so bind the name rather than qualifying each use.
using Status = Grpc.Core.Status;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Grpc;
/// <summary>
/// Central-side ingress for a node's deployment acknowledgment (ClusterClient→gRPC migration
/// Phase 1B). Decodes an <see cref="ApplyAckMessage"/> and hands it to the node-local
/// <c>CentralCommunicationActor</c> — the same actor an Akka ClusterClient receptionist delivered
/// to, whose forward-to-the-coordinator path is untouched. Registered only on admin-role nodes
/// and only when <c>ConfigServe:GrpcListenPort &gt; 0</c>.
/// </summary>
/// <remarks>
/// <para>
/// <b>The actor is resolved per call, not in the constructor.</b> Kestrel answers before
/// <c>Akka.Hosting</c> has finished spawning the actor graph, so a ctor lookup would throw
/// during the boot window and surface as an opaque <c>Unknown</c>. Resolving per call turns
/// that window into <see cref="StatusCode.Unavailable"/> — the retryable status — so the
/// node's next attempt lands rather than the deploy recording a permanent failure against a
/// node that applied fine.
/// </para>
/// <para>
/// <b>Decoding is graded before the registry.</b> A message that cannot decode is
/// <see cref="StatusCode.InvalidArgument"/> even when the actor is also missing: Unavailable
/// invites a retry, and retrying a message that can never decode is a loop. The decode itself
/// never guesses (see <see cref="MeshTransportDtoMapper"/>) — an unspecified outcome, an
/// unparseable id or a blank node name is refused rather than resolved to a plausible value,
/// because the deploy path seals a revision on <c>Applied</c> and a guessed ack seals a
/// deployment nobody applied.
/// </para>
/// <para>
/// <b>The rejection log carries the wire correlation id verbatim.</b> The inner ids are
/// exactly what may have failed to parse, so the only value guaranteed readable at that point
/// is the raw string — which is what an operator has to correlate the rejection against the
/// node's own log.
/// </para>
/// <para>
/// <b>One-way semantics, preserved.</b> <c>Tell</c>, not <c>Ask</c>: the returned
/// <see cref="ReportAck"/> means "handed to the comm actor", never "the deployment is
/// complete" — the same promise the receptionist made. The comm actor forwards to the
/// coordinator singleton proxy, so an ack landing on either central node routes identically.
/// </para>
/// </remarks>
public sealed class CentralCommandGrpcService : GeneratedServiceBase
{
private readonly ActorRegistry _registry;
private readonly ILogger<CentralCommandGrpcService> _log;
/// <summary>Initializes a new instance of the <see cref="CentralCommandGrpcService"/> class.</summary>
/// <param name="registry">The actor registry holding the node's <c>CentralCommunicationActor</c>.</param>
/// <param name="log">Logger.</param>
public CentralCommandGrpcService(ActorRegistry registry, ILogger<CentralCommandGrpcService> log)
{
_registry = registry;
_log = log;
}
/// <inheritdoc />
public override Task<ReportAck> ReportApply(ApplyAckMessage request, ServerCallContext context)
{
ArgumentNullException.ThrowIfNull(request);
ArgumentNullException.ThrowIfNull(context);
ApplyAck ack;
try
{
// Exactly the three the mapper documents: an unspecified/unknown outcome
// (InvalidOperationException), an unparseable id (FormatException) and a blank node id
// (ArgumentException). Deliberately not a blanket catch — anything else escaping the
// mapper is a bug on this side, and reporting it to the caller as their InvalidArgument
// would send an operator to debug a node that sent a perfectly good ack.
ack = MeshTransportDtoMapper.FromProto(request);
}
catch (Exception ex) when (ex is InvalidOperationException or FormatException or ArgumentException)
{
// Logged with the RAW correlation id: the decode may have failed on the ids themselves.
_log.LogWarning(
ex,
"Rejected an ApplyAck (correlation '{CorrelationId}') from {Peer}: it could not be "
+ "decoded, so the deployment it names is NOT acknowledged on this node",
request.CorrelationId, context.Peer);
throw new RpcException(new Status(StatusCode.InvalidArgument, "undecodable ApplyAck"));
}
if (!_registry.TryGet<CentralCommunicationActorKey>(out var actor))
{
_log.LogWarning(
"Received an ApplyAck for {DeploymentId} from {NodeId} before the central "
+ "communication actor was registered on this node; refusing with Unavailable so the "
+ "caller retries rather than recording a failure against a node that applied",
ack.DeploymentId, ack.NodeId);
throw new RpcException(new Status(StatusCode.Unavailable, "central command actor not ready"));
}
// Tell, not Ask: the RPC's own response is the acknowledgment, and the comm actor's existing
// Forward-to-coordinator path is what it always was.
actor.Tell(ack);
_log.LogDebug(
"ApplyAck for {DeploymentId} from {NodeId} ({Outcome}) handed to the central communication actor",
ack.DeploymentId, ack.NodeId, ack.Outcome);
return Task.FromResult(new ReportAck());
}
}
@@ -744,6 +744,16 @@ if (hasDriver && syncListenPort > 0)
if (hasAdmin && configServeGrpcPort > 0)
{
app.MapGrpcService<DeploymentArtifactGrpcService>();
// Central command ingress (ClusterClient→gRPC migration): the node→central deployment ack. It
// rides the SAME listener rather than claiming a port of its own — a second cleartext h2c port
// would buy nothing but another firewall rule, and both endpoints are admin-role and dialled by
// the same driver nodes. Deliberately NOT gated on MeshTransport:Mode: mapping it under Dps
// costs nothing (nothing dials it) whereas gating it would mean flipping the mode on a running
// fleet requires a central restart before any node can ack — the same reasoning that registers
// CentralCommunicationActor in both modes. CentralCommandAuthInterceptor (added at AddGrpc
// above) is the ONLY inbound auth and fail-closes per cluster.
app.MapGrpcService<CentralCommandGrpcService>();
}
// Driver-node live-telemetry stream (per-cluster mesh Phase 5). Central dials in; gated by