diff --git a/docs/plans/2026-07-22-clusterclient-to-grpc-plan.md b/docs/plans/2026-07-22-clusterclient-to-grpc-plan.md index dd399b4b..77a52dcc 100644 --- a/docs/plans/2026-07-22-clusterclient-to-grpc-plan.md +++ b/docs/plans/2026-07-22-clusterclient-to-grpc-plan.md @@ -317,7 +317,7 @@ Critical path ≈ 1B: **~4–6 weeks total**, matching the design estimate. **Phase 1B — site command plane** (worktree, `feat/grpc-sitecommand`) - [x] T1B.1 `site_command.proto` (6 oneof RPCs / 28 commands) + `SiteCommandDtoMapper` + round-trip golden tests (all 28 commands + 22 reply shapes + 18 nested types; reflection-driven coverage guard over the mapper surface and the generated oneof descriptors) -- [x] T1B.2 `SiteCommandDispatcher` refactor (actor + new `SiteCommandGrpcService` share it; parked stays node-local) +- [x] T1B.2 `SiteCommandDispatcher` refactor (actor + new `SiteCommandGrpcService` share it; parked stays node-local; failover ack-before-Leave via dry-run resolve + deferred `CommitLeave`; interceptor `DefaultGatedPrefixes` extended to `SiteCommandService`) - [x] T1B.3 `ISiteCommandTransport` in `CentralCommunicationActor` (Akka extract + Grpc impl), `SitePairChannelProvider` (Site entity Grpc columns + DB refresh loop), `SiteTransport` flag default `Akka` - [x] T1B.4 Tests: dispatcher routing ×28, actor envelope/reply plumbing, TestServer service tests, existing Communication suites green - [ ] 1B DoD: rig central on `Grpc` for site-a proves full command matrix incl. standby parked retry; rebased on 1A; PR merged diff --git a/src/ZB.MOM.WW.ScadaBridge.Communication/Actors/SiteCommandDispatcher.cs b/src/ZB.MOM.WW.ScadaBridge.Communication/Actors/SiteCommandDispatcher.cs new file mode 100644 index 00000000..e37456ef --- /dev/null +++ b/src/ZB.MOM.WW.ScadaBridge.Communication/Actors/SiteCommandDispatcher.cs @@ -0,0 +1,303 @@ +using Akka.Actor; +using ZB.MOM.WW.ScadaBridge.Commons.Messages.Artifacts; +using ZB.MOM.WW.ScadaBridge.Commons.Messages.DataConnection; +using ZB.MOM.WW.ScadaBridge.Commons.Messages.DebugView; +using ZB.MOM.WW.ScadaBridge.Commons.Messages.Deployment; +using ZB.MOM.WW.ScadaBridge.Commons.Messages.InboundApi; +using ZB.MOM.WW.ScadaBridge.Commons.Messages.Lifecycle; +using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management; +using ZB.MOM.WW.ScadaBridge.Commons.Messages.RemoteQuery; +using ZB.MOM.WW.ScadaBridge.Communication.Grpc; + +namespace ZB.MOM.WW.ScadaBridge.Communication.Actors; + +/// +/// The single routing truth for the 28 migrated central→site commands. Given one +/// of those command records it decides which local target answers it — +/// the Deployment Manager singleton proxy, the artifact/event-log/parked-message +/// handlers, or the node-local failover path — preserving EXACTLY the targets and +/// null-guard semantics used when this routing +/// lived inline. +/// +/// +/// +/// Two transports call this one unit: the Akka +/// (ClusterClient) and the new SiteCommandGrpcService (gRPC). Centralising the +/// table means the two can never drift on where a command goes. Each transport keeps +/// its own send mechanics — the actor Forwards (preserving the Ask sender), the +/// gRPC service Asks and encodes the reply — but both read the same +/// here. +/// +/// +/// The parked-message handler stays node-local on purpose. A parked retry or +/// discard must run on the node that holds the replicated store row, so parked +/// commands route to the per-node _parkedMessageHandler — never onto the +/// singleton proxy (design §7.3). This is the same target the actor used; the +/// extraction does not "fix" it. +/// +/// +/// Excluded by design: IntegrationCallRequest — the 29th command, dead at +/// both ends. It never enters this dispatcher; the actor keeps its own vestigial handler +/// for it (28 of 29 migrate). +/// +/// +public sealed class SiteCommandDispatcher +{ + /// How a resolved command is delivered to its target. + public enum RouteDisposition + { + /// Forward/Ask and route its reply back. + Forward, + + /// + /// Tell and answer immediately with + /// — the fire-and-forget path (only + /// , which the downstream never acks). + /// The actor Forwards it and sends nothing back; the gRPC transport Tells and + /// returns the synthetic ack so a unary RPC still answers. + /// + TellFireAndForget, + + /// + /// No target is available (a null-guarded handler is unregistered); answer with the + /// synthetic — the "handler not available" reply the actor + /// produced inline. + /// + ImmediateReply + } + + /// A resolved routing decision for one command. + /// How the command is delivered. + /// The target actor for /; null for an immediate reply. + /// The synthetic reply for /; null for a forward. + public readonly record struct Route(RouteDisposition Disposition, IActorRef? Target, object? Reply); + + /// The outcome of preparing a failover: the ack to send now, plus the leave to run after. + /// The ack the caller must send back before the node leaves. + /// + /// When non-null (accepted only), invoking it performs the real graceful + /// Cluster.Leave. It is deliberately deferred so the transport can flush the + /// first — a caller reaching the very node that is about to leave still + /// receives the ack rather than a broken stream. + /// + public readonly record struct FailoverOutcome(SiteFailoverAck Ack, Action? CommitLeave); + + private readonly string _siteId; + private readonly IActorRef _deploymentManagerProxy; + + // (role, dryRun) -> leaving node address, or null when there is no standby. + // dryRun:true resolves the target WITHOUT leaving; dryRun:false performs the Leave. + private readonly Func _resolveFailover; + + // Registered at runtime via the actor's RegisterLocalHandler flow. Written on the actor + // thread, read by both the actor and the gRPC service (a Kestrel thread), so volatile. + private volatile IActorRef? _eventLogHandler; + private volatile IActorRef? _parkedMessageHandler; + private volatile IActorRef? _artifactHandler; + + /// Creates the dispatcher. + /// This site's identifier, stamped into synthetic replies and matched by the failover guard. + /// The local Deployment Manager singleton proxy — the target for all lifecycle/OPC UA/query/route commands. + /// + /// Resolves (and, when dryRun is false, performs) the graceful leave of the oldest + /// Up member in a role scope. Injected so tests need no real cluster. + /// + public SiteCommandDispatcher( + string siteId, + IActorRef deploymentManagerProxy, + Func resolveFailover) + { + ArgumentNullException.ThrowIfNull(siteId); + ArgumentNullException.ThrowIfNull(deploymentManagerProxy); + ArgumentNullException.ThrowIfNull(resolveFailover); + + _siteId = siteId; + _deploymentManagerProxy = deploymentManagerProxy; + _resolveFailover = resolveFailover; + } + + /// Registers the site event-log query handler (a cluster singleton proxy). + /// The event-log handler. + public void RegisterEventLogHandler(IActorRef handler) => _eventLogHandler = handler; + + /// Registers the node-local parked-message handler (the replicated-store owner on this node). + /// The parked-message handler. + public void RegisterParkedMessageHandler(IActorRef handler) => _parkedMessageHandler = handler; + + /// Registers the artifact-deployment handler. + /// The artifact handler. + public void RegisterArtifactHandler(IActorRef handler) => _artifactHandler = handler; + + /// + /// Resolves how one of the 27 non-failover commands is delivered. + /// is handled separately via / because its + /// leave is deferred; passing it here throws. + /// + /// The command to route. + /// The routing decision. + /// The command is not a migrated site command (or is failover). + public Route ResolveRoute(object command) + { + ArgumentNullException.ThrowIfNull(command); + + return command switch + { + // ── Deployment + instance lifecycle → Deployment Manager singleton proxy ── + RefreshDeploymentCommand => ToProxy(), + EnableInstanceCommand => ToProxy(), + DisableInstanceCommand => ToProxy(), + DeleteInstanceCommand => ToProxy(), + DeploymentStateQueryRequest => ToProxy(), + + // ── Artifact deployment → artifact handler (null-guarded) ── + DeployArtifactsCommand c => _artifactHandler is { } h + ? Forwarded(h) + : Immediate(new ArtifactDeploymentResponse( + c.DeploymentId, _siteId, false, "Artifact handler not available", DateTimeOffset.UtcNow)), + + // ── Interactive OPC UA / MxGateway → Deployment Manager singleton proxy ── + // The singleton always lands on the active node, which owns the live sessions. + BrowseNodeCommand => ToProxy(), + SearchAddressSpaceCommand => ToProxy(), + ReadTagValuesCommand => ToProxy(), + VerifyEndpointCommand => ToProxy(), + TrustServerCertCommand => ToProxy(), + ListServerCertsCommand => ToProxy(), + RemoveServerCertCommand => ToProxy(), + WriteTagRequest => ToProxy(), + + // ── Remote queries: event log (null-guarded) + debug view (proxy) ── + EventLogQueryRequest r => _eventLogHandler is { } h + ? Forwarded(h) + : Immediate(new EventLogQueryResponse( + r.CorrelationId, _siteId, [], null, false, false, + "Event log handler not available", DateTimeOffset.UtcNow)), + DebugSnapshotRequest => ToProxy(), + SubscribeDebugViewRequest => ToProxy(), + // Fire-and-forget: the Deployment Manager never acks an unsubscribe, so the gRPC + // transport Tells it and returns the synthetic ack; the actor just Forwards. + UnsubscribeDebugViewRequest => new Route( + RouteDisposition.TellFireAndForget, _deploymentManagerProxy, UnsubscribeDebugViewAck.Instance), + + // ── Parked store-and-forward actions → node-local parked handler (null-guarded) ── + ParkedMessageQueryRequest r => _parkedMessageHandler is { } h + ? Forwarded(h) + : Immediate(new ParkedMessageQueryResponse( + r.CorrelationId, _siteId, [], 0, r.PageNumber, r.PageSize, false, + "Parked message handler not available", DateTimeOffset.UtcNow)), + ParkedMessageRetryRequest r => _parkedMessageHandler is { } h + ? Forwarded(h) + : Immediate(new ParkedMessageRetryResponse( + r.CorrelationId, false, "Parked message handler not available")), + ParkedMessageDiscardRequest r => _parkedMessageHandler is { } h + ? Forwarded(h) + : Immediate(new ParkedMessageDiscardResponse( + r.CorrelationId, false, "Parked message handler not available")), + RetryParkedOperation r => _parkedMessageHandler is { } h + ? Forwarded(h) + : Immediate(new ParkedOperationActionAck( + r.CorrelationId, Applied: false, "Parked message handler not available")), + DiscardParkedOperation r => _parkedMessageHandler is { } h + ? Forwarded(h) + : Immediate(new ParkedOperationActionAck( + r.CorrelationId, Applied: false, "Parked message handler not available")), + + // ── Inbound-API Route.To() relays → Deployment Manager singleton proxy ── + RouteToCallRequest => ToProxy(), + RouteToGetAttributesRequest => ToProxy(), + RouteToSetAttributesRequest => ToProxy(), + RouteToWaitForAttributeRequest => ToProxy(), + + TriggerSiteFailover => throw new ArgumentException( + "TriggerSiteFailover is handled by PrepareFailover/HandleFailover, not ResolveRoute.", + nameof(command)), + + _ => throw new ArgumentException( + $"'{command.GetType().Name}' is not a migrated site command.", nameof(command)) + }; + + Route ToProxy() => Forwarded(_deploymentManagerProxy); + } + + private static Route Forwarded(IActorRef target) => new(RouteDisposition.Forward, target, null); + + private static Route Immediate(object reply) => new(RouteDisposition.ImmediateReply, null, reply); + + /// + /// The actor's failover path: resolve the standby AND issue the leave in one step (today's + /// coupled behaviour over ClusterClient, where Tell merely enqueues the ack so leave + /// order is immaterial), then return the ack. + /// + /// The failover command. + /// The ack to send back. + public SiteFailoverAck HandleFailover(TriggerSiteFailover msg) + => FailoverCore(msg, commitLeaveImmediately: true).Ack; + + /// + /// The gRPC failover path: resolve the standby WITHOUT leaving, build the ack, and hand back a + /// deferred . The caller must send the ack before + /// invoking the leave — otherwise a caller reaching the leaving node sees a broken stream + /// instead of its ack (the ack-before-Leave rule). + /// + /// The failover command. + /// The ack and the deferred leave (leave is null when refused). + public FailoverOutcome PrepareFailover(TriggerSiteFailover msg) + => FailoverCore(msg, commitLeaveImmediately: false); + + private FailoverOutcome FailoverCore(TriggerSiteFailover msg, bool commitLeaveImmediately) + { + ArgumentNullException.ThrowIfNull(msg); + + // A misrouted command must be refused, not silently acted on — acting would fail over a + // site the operator never selected. Checked before resolving so the resolver is untouched. + if (!string.Equals(msg.SiteId, _siteId, StringComparison.Ordinal)) + { + return new FailoverOutcome( + new SiteFailoverAck( + msg.CorrelationId, Accepted: false, TargetAddress: null, + ErrorMessage: $"Command addressed to site '{msg.SiteId}' but this node serves '{_siteId}'."), + CommitLeave: null); + } + + // Site singletons are scoped to the site-specific role, so failover must target that role. + var role = $"site-{_siteId}"; + try + { + if (commitLeaveImmediately) + { + var target = _resolveFailover(role, false); + if (target is null) + { + return new FailoverOutcome(NoPeerAck(msg), CommitLeave: null); + } + + return new FailoverOutcome( + new SiteFailoverAck(msg.CorrelationId, Accepted: true, target, ErrorMessage: null), + CommitLeave: null); + } + + var resolved = _resolveFailover(role, true); + if (resolved is null) + { + return new FailoverOutcome(NoPeerAck(msg), CommitLeave: null); + } + + return new FailoverOutcome( + new SiteFailoverAck(msg.CorrelationId, Accepted: true, resolved, ErrorMessage: null), + CommitLeave: () => _resolveFailover(role, false)); + } + catch (Exception ex) + { + // A fault must be reported to the operator, never thrown into supervision (over + // ClusterClient) or surfaced as a broken stream (over gRPC). + return new FailoverOutcome( + new SiteFailoverAck( + msg.CorrelationId, Accepted: false, TargetAddress: null, ErrorMessage: ex.Message), + CommitLeave: null); + } + } + + private static SiteFailoverAck NoPeerAck(TriggerSiteFailover msg) => new( + msg.CorrelationId, Accepted: false, TargetAddress: null, + ErrorMessage: "No standby available — failing over a lone node would be an outage."); +} diff --git a/src/ZB.MOM.WW.ScadaBridge.Communication/Actors/SiteCommunicationActor.cs b/src/ZB.MOM.WW.ScadaBridge.Communication/Actors/SiteCommunicationActor.cs index b8a2a6f0..203f5094 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Communication/Actors/SiteCommunicationActor.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Communication/Actors/SiteCommunicationActor.cs @@ -36,13 +36,20 @@ public class SiteCommunicationActor : ReceiveActor, IWithTimers /// do not need a real cluster. /// private readonly Func _isActiveCheck; - private readonly Func _failOverRole; /// /// Reference to the local Deployment Manager singleton proxy. /// private readonly IActorRef _deploymentManagerProxy; + /// + /// The single routing truth for the 28 migrated central→site commands, shared with + /// the gRPC SiteCommandGrpcService so the two transports cannot drift. In + /// production it is created by the Host and passed in (so the gRPC server holds the + /// same instance); when unset (tests) the actor builds its own from the failover seam. + /// + private readonly SiteCommandDispatcher _dispatcher; + /// /// The site→central transport. Finalized in to the injected instance, /// or a default (ClusterClient) when none is supplied — so @@ -54,13 +61,10 @@ public class SiteCommunicationActor : ReceiveActor, IWithTimers private readonly ICentralTransport? _injectedTransport; /// - /// Local actor references for routing specific message patterns. - /// Populated via registration messages. + /// Handler for the vestigial — the one command NOT + /// migrated to the dispatcher (dead at both ends), so it still routes on the actor. /// - private IActorRef? _eventLogHandler; - private IActorRef? _parkedMessageHandler; private IActorRef? _integrationHandler; - private IActorRef? _artifactHandler; /// Akka timer scheduler injected by the framework via . public ITimerScheduler Timers { get; set; } = null!; @@ -82,24 +86,41 @@ public class SiteCommunicationActor : ReceiveActor, IWithTimers /// Host injects a when /// ScadaBridge:Communication:CentralTransport is Grpc. /// + /// + /// The shared (production: created by the Host and also + /// handed to the gRPC command service, so both transports route through one instance). + /// null makes the actor build its own from — the shape + /// the existing tests use. + /// public SiteCommunicationActor( string siteId, CommunicationOptions options, IActorRef deploymentManagerProxy, Func? isActiveCheck = null, Func? failOverRole = null, - ICentralTransport? transport = null) + ICentralTransport? transport = null, + SiteCommandDispatcher? dispatcher = null) { _siteId = siteId; _options = options; _deploymentManagerProxy = deploymentManagerProxy; _isActiveCheck = isActiveCheck ?? DefaultIsActiveCheck; - _failOverRole = failOverRole ?? DefaultFailOverRole; _injectedTransport = transport; // Finalized in PreStart (where _log is usable for the default transport); assigned here // too so the field is definitely-assigned for the constructor's Receive closures. _transport = transport!; + // When no shared dispatcher is supplied, build one over the same failover seam the + // actor used before extraction: an injected Func (tests) that both resolves and leaves, + // or the shared ClusterFailoverCoordinator. The actor only ever commits the leave + // immediately (dryRun:false), so an injected coupled Func fits unchanged. + var system = Context.System; + Func resolveFailover = failOverRole is not null + ? (role, _) => failOverRole(role) + : (role, dryRun) => + ClusterState.ClusterFailoverCoordinator.FailOverOldest(system, role, dryRun)?.ToString(); + _dispatcher = dispatcher ?? new SiteCommandDispatcher(siteId, deploymentManagerProxy, resolveFailover); + // Registration. Feeding the ClusterClient into the transport is a no-op unless the // default/Akka transport is in use — the gRPC transport dials configured endpoints and // never receives this message (the Host does not create a ClusterClient for it). @@ -109,37 +130,44 @@ public class SiteCommunicationActor : ReceiveActor, IWithTimers }); Receive(HandleRegisterLocalHandler); - // Pattern 1: Instance Deployment — forward to Deployment Manager - Receive(msg => - { - _log.Debug("Routing RefreshDeploymentCommand for {0} to DeploymentManager", msg.InstanceUniqueName); - _deploymentManagerProxy.Forward(msg); - }); + // ── The 27 migrated central→site commands (28th is failover, below) all route + // through the shared SiteCommandDispatcher — the single routing truth also used by + // the gRPC SiteCommandGrpcService. The actor's job per command is unchanged: Forward + // to the resolved target (preserving the central Ask sender so replies route straight + // back to the waiting Ask), or Tell the caller the dispatcher's synthetic reply when a + // null-guarded handler is unregistered. See SiteCommandDispatcher for the target of + // each command and why the parked handler stays node-local. + Receive(cmd => DispatchCommand(cmd)); + Receive(cmd => DispatchCommand(cmd)); + Receive(cmd => DispatchCommand(cmd)); + Receive(cmd => DispatchCommand(cmd)); + Receive(cmd => DispatchCommand(cmd)); + Receive(cmd => DispatchCommand(cmd)); + Receive(cmd => DispatchCommand(cmd)); + Receive(cmd => DispatchCommand(cmd)); + Receive(cmd => DispatchCommand(cmd)); + Receive(cmd => DispatchCommand(cmd)); + Receive(cmd => DispatchCommand(cmd)); + Receive(cmd => DispatchCommand(cmd)); + Receive(cmd => DispatchCommand(cmd)); + Receive(cmd => DispatchCommand(cmd)); + Receive(cmd => DispatchCommand(cmd)); + Receive(cmd => DispatchCommand(cmd)); + Receive(cmd => DispatchCommand(cmd)); + Receive(cmd => DispatchCommand(cmd)); + Receive(cmd => DispatchCommand(cmd)); + Receive(cmd => DispatchCommand(cmd)); + Receive(cmd => DispatchCommand(cmd)); + Receive(cmd => DispatchCommand(cmd)); + Receive(cmd => DispatchCommand(cmd)); + Receive(cmd => DispatchCommand(cmd)); + Receive(cmd => DispatchCommand(cmd)); + Receive(cmd => DispatchCommand(cmd)); + Receive(cmd => DispatchCommand(cmd)); - // Pattern 2: Lifecycle — forward to Deployment Manager - Receive(msg => _deploymentManagerProxy.Forward(msg)); - Receive(msg => _deploymentManagerProxy.Forward(msg)); - Receive(msg => _deploymentManagerProxy.Forward(msg)); - - // Query-the-site-before-redeploy — forward to - // the Deployment Manager, which owns the deployed-config store and - // answers with the instance's currently-applied deployment identity. - Receive(msg => _deploymentManagerProxy.Forward(msg)); - - // Pattern 3: Artifact Deployment — forward to artifact handler if registered - Receive(msg => - { - if (_artifactHandler != null) - _artifactHandler.Forward(msg); - else - { - _log.Warning("No artifact handler registered, replying with failure"); - Sender.Tell(new ArtifactDeploymentResponse( - msg.DeploymentId, _siteId, false, "Artifact handler not available", DateTimeOffset.UtcNow)); - } - }); - - // Pattern 4: Integration Routing — forward to integration handler + // Integration Routing — the 29th command, NOT migrated to the dispatcher (dead at + // both ends; no production code registers the handler). Kept on the actor so the + // dispatcher's command surface stays the 28 that actually migrate. Receive(msg => { if (_integrationHandler != null) @@ -151,143 +179,15 @@ public class SiteCommunicationActor : ReceiveActor, IWithTimers } }); - // Pattern 5: Debug View — forward to Deployment Manager (which routes to Instance Actor) - Receive(msg => _deploymentManagerProxy.Forward(msg)); - Receive(msg => _deploymentManagerProxy.Forward(msg)); - - // Pattern 6a: Debug Snapshot (one-shot) — forward to Deployment Manager - Receive(msg => _deploymentManagerProxy.Forward(msg)); - - // Inbound API Route.To() — forward to Deployment Manager for instance routing - Receive(msg => _deploymentManagerProxy.Forward(msg)); - Receive(msg => _deploymentManagerProxy.Forward(msg)); - Receive(msg => _deploymentManagerProxy.Forward(msg)); - Receive(msg => _deploymentManagerProxy.Forward(msg)); - - // OPC UA Tag Browser (interactive design-time query) — forward to the - // Deployment Manager singleton, which always lands on the active site - // node. Routing to the site-local /user/dcl-manager directly is wrong - // because the standby node has a dcl-manager too, but its - // DataConnectionActor children (which own the live OPC UA sessions) - // only exist on the singleton's node. The singleton then re-forwards - // to its own /user/dcl-manager, which DOES have the connection. - Receive(msg => _deploymentManagerProxy.Forward(msg)); - - // Test Bindings (interactive design-time read) — same routing rationale - // as BrowseNodeCommand above: the singleton always lands on the - // active site node, which is the node that owns the DataConnectionActor - // children holding the live OPC UA sessions. - Receive(msg => _deploymentManagerProxy.Forward(msg)); - - // OPC UA tag-picker address-space search and secured-write execute - // — same singleton routing rationale as BrowseNodeCommand above: the - // DataConnectionActor children that own the live OPC UA sessions exist only - // on the singleton's (active) node, so these must hop through the Deployment - // Manager proxy too. Without these forwards the commands dead-letter and the - // central Ask times out. Forward preserves the central Ask sender so the - // result routes straight back to the waiting Ask. - Receive(msg => _deploymentManagerProxy.Forward(msg)); - Receive(msg => _deploymentManagerProxy.Forward(msg)); - - // OPC UA endpoint Verify — probes a (possibly unsaved) endpoint config - // WITHOUT persisting it. The Deployment Manager singleton's dcl-manager runs - // the probe directly (no existing connection required), so — like the - // commands above — Verify routes through the singleton's active node. - Receive(msg => _deploymentManagerProxy.Forward(msg)); - - // OPC UA server-certificate trust management — forward to the - // Deployment Manager singleton, which owns the cross-node trust broadcast. - // The trusted-peer PKI store is node-wide per site node, so a trust/remove - // decision must reach BOTH nodes' CertStoreActor; the singleton broadcasts - // to every site node (list answers from the singleton's own node). The - // singleton always lands on the active node, the same routing rationale as - // BrowseNodeCommand above. Forward preserves the central Ask sender so the - // CertTrustResult routes straight back to the waiting Ask. - Receive(msg => _deploymentManagerProxy.Forward(msg)); - Receive(msg => _deploymentManagerProxy.Forward(msg)); - Receive(msg => _deploymentManagerProxy.Forward(msg)); - - // Pattern 7: Remote Queries - Receive(msg => - { - if (_eventLogHandler != null) - _eventLogHandler.Forward(msg); - else - { - Sender.Tell(new EventLogQueryResponse( - msg.CorrelationId, _siteId, [], null, false, false, - "Event log handler not available", DateTimeOffset.UtcNow)); - } - }); - - Receive(msg => - { - if (_parkedMessageHandler != null) - _parkedMessageHandler.Forward(msg); - else - { - Sender.Tell(new ParkedMessageQueryResponse( - msg.CorrelationId, _siteId, [], 0, msg.PageNumber, msg.PageSize, false, - "Parked message handler not available", DateTimeOffset.UtcNow)); - } - }); - - Receive(msg => - { - if (_parkedMessageHandler != null) - _parkedMessageHandler.Forward(msg); - else - { - Sender.Tell(new ParkedMessageRetryResponse( - msg.CorrelationId, false, "Parked message handler not available")); - } - }); - - Receive(msg => - { - if (_parkedMessageHandler != null) - _parkedMessageHandler.Forward(msg); - else - { - Sender.Tell(new ParkedMessageDiscardResponse( - msg.CorrelationId, false, "Parked message handler not available")); - } - }); - - // Central→site Retry/Discard relay for parked cached - // operations. SiteCallAuditActor relays these over the command/control - // channel; the parked-message handler executes them against the local - // S&F buffer and replies a ParkedOperationActionAck that routes back to - // the relaying SiteCallAuditActor's Ask. - Receive(msg => - { - if (_parkedMessageHandler != null) - _parkedMessageHandler.Forward(msg); - else - { - Sender.Tell(new ParkedOperationActionAck( - msg.CorrelationId, Applied: false, "Parked message handler not available")); - } - }); - - Receive(msg => - { - if (_parkedMessageHandler != null) - _parkedMessageHandler.Forward(msg); - else - { - Sender.Tell(new ParkedOperationActionAck( - msg.CorrelationId, Applied: false, "Parked message handler not available")); - } - }); - // Central→site manual failover relay. Central and the site are separate clusters, // so central can only ask — this node performs the graceful Leave locally, scoped to // the SITE-SPECIFIC role, because that is what site singletons (the Deployment // Manager) are placed on. Either node may receive this (the actor is per-node, not a // singleton, and contact rotation picks whichever answers); the target is resolved - // from cluster state, not from who received the message. - Receive(HandleTriggerSiteFailover); + // from cluster state, not from who received the message. Over ClusterClient the ack + // Tell merely enqueues, so the dispatcher resolves-and-leaves in one step (the gRPC + // transport defers the leave to keep ack-before-Leave — see PrepareFailover). + Receive(msg => Sender.Tell(_dispatcher.HandleFailover(msg))); // The seven site→central sends now delegate to the injected transport (ClusterClient by // default, gRPC when configured). Each handler captures the current Sender as the reply @@ -352,21 +252,47 @@ public class SiteCommunicationActor : ReceiveActor, IWithTimers _options.ApplicationHeartbeatInterval); } + /// + /// Executes a resolved command route within the actor: Forward to the target (preserving the + /// central Ask sender so the reply routes straight back to the waiting Ask), or — when a + /// null-guarded handler is unregistered — Tell the caller the dispatcher's synthetic reply. + /// The fire-and-forget disposition (UnsubscribeDebugView) is a plain Forward here, exactly as + /// before: over ClusterClient the site never acked it, so the synthetic ack is a gRPC-only + /// concern. + /// + /// The migrated central→site command to route. + private void DispatchCommand(object command) + { + var route = _dispatcher.ResolveRoute(command); + switch (route.Disposition) + { + case SiteCommandDispatcher.RouteDisposition.ImmediateReply: + Sender.Tell(route.Reply!); + break; + default: + // Forward and TellFireAndForget both Forward on the actor path. + route.Target!.Forward(command); + break; + } + } + private void HandleRegisterLocalHandler(RegisterLocalHandler msg) { + // The migrated handlers live on the shared dispatcher so the gRPC command service sees the + // same registrations. Integration is the one command kept on the actor (see the receive). switch (msg.HandlerType) { case LocalHandlerType.EventLog: - _eventLogHandler = msg.Handler; + _dispatcher.RegisterEventLogHandler(msg.Handler); break; case LocalHandlerType.ParkedMessages: - _parkedMessageHandler = msg.Handler; + _dispatcher.RegisterParkedMessageHandler(msg.Handler); break; case LocalHandlerType.Integration: _integrationHandler = msg.Handler; break; case LocalHandlerType.Artifacts: - _artifactHandler = msg.Handler; + _dispatcher.RegisterArtifactHandler(msg.Handler); break; } @@ -431,69 +357,6 @@ public class SiteCommunicationActor : ReceiveActor, IWithTimers private bool DefaultIsActiveCheck() => ClusterState.ActiveNodeEvaluator.SelfIsOldestUp(Cluster.Get(Context.System)); - /// - /// Handles a central-initiated site failover. Refuses a command addressed to a different - /// site (a misroute must never silently fail over a site the operator did not select) and - /// refuses when the site pair has no peer to take over. The ack is sent BEFORE the Leave - /// takes effect on the wire, so it still reaches central even when this node is the one - /// leaving. - /// - private void HandleTriggerSiteFailover(TriggerSiteFailover msg) - { - if (!string.Equals(msg.SiteId, _siteId, StringComparison.Ordinal)) - { - _log.Warning( - "Refusing TriggerSiteFailover addressed to site {Requested}; this node serves {Actual}", - msg.SiteId, _siteId); - Sender.Tell(new SiteFailoverAck( - msg.CorrelationId, Accepted: false, TargetAddress: null, - ErrorMessage: $"Command addressed to site '{msg.SiteId}' but this node serves '{_siteId}'.")); - return; - } - - var role = $"site-{_siteId}"; - try - { - var target = _failOverRole(role); - if (target is null) - { - _log.Warning( - "Refusing TriggerSiteFailover for {SiteId}: fewer than 2 Up members in role {Role}, " - + "so there is no standby to take over", _siteId, role); - Sender.Tell(new SiteFailoverAck( - msg.CorrelationId, Accepted: false, TargetAddress: null, - ErrorMessage: "No standby available — failing over a lone node would be an outage.")); - return; - } - - _log.Warning( - "Manual failover requested by central for site {SiteId}: {Target} is leaving the " - + "site cluster gracefully; its singletons hand over to the standby.", _siteId, target); - Sender.Tell(new SiteFailoverAck(msg.CorrelationId, Accepted: true, target, ErrorMessage: null)); - } - catch (Exception ex) - { - // A fault here must be reported to the operator, not thrown into supervision — - // restarting the communication actor would drop central's Ask into a timeout and - // lose the reason. - _log.Error(ex, "TriggerSiteFailover for {SiteId} faulted", _siteId); - Sender.Tell(new SiteFailoverAck( - msg.CorrelationId, Accepted: false, TargetAddress: null, ErrorMessage: ex.Message)); - } - } - - /// - /// Production failover action: gracefully Leave the oldest Up member carrying - /// , via the shared - /// so the central and site paths cannot drift. Injected in tests for the same reason - /// is — a real Leave needs Akka.Cluster in the - /// ActorSystem, which the TestKit system does not load. - /// - /// Site-specific role scope. - /// Address of the leaving node, or null when there is no peer. - private string? DefaultFailOverRole(string role) => - ClusterState.ClusterFailoverCoordinator.FailOverOldest(Context.System, role)?.ToString(); - // ── Internal messages ── internal record SendHeartbeat; diff --git a/src/ZB.MOM.WW.ScadaBridge.Communication/Grpc/SiteCommandGrpcService.cs b/src/ZB.MOM.WW.ScadaBridge.Communication/Grpc/SiteCommandGrpcService.cs new file mode 100644 index 00000000..b0fc482d --- /dev/null +++ b/src/ZB.MOM.WW.ScadaBridge.Communication/Grpc/SiteCommandGrpcService.cs @@ -0,0 +1,227 @@ +using Akka.Actor; +using Grpc.Core; +using Microsoft.Extensions.Logging; +using ZB.MOM.WW.ScadaBridge.Commons.Messages.RemoteQuery; +using ZB.MOM.WW.ScadaBridge.Communication.Actors; +using GrpcStatus = Grpc.Core.Status; + +namespace ZB.MOM.WW.ScadaBridge.Communication.Grpc; + +/// +/// gRPC front door for the central→site command plane: decodes a proto command (via +/// ), routes it through the ONE +/// the Akka SiteCommunicationActor also uses, and +/// encodes the reply. One routing truth, two transports. +/// +/// +/// +/// Mapped in the site branch next to SiteStreamGrpcServer, behind the same +/// ControlPlaneAuthInterceptor PSK gate and the same readiness convention: calls are +/// rejected with until is called +/// once the site actor system is up (mirrors SiteStreamGrpcServer.SetReady). +/// +/// +/// Coexistence — server-side only. This makes the site ALSO listen on gRPC for commands; +/// nothing central flips to gRPC here (that is T1B.3). Central still dials sites via ClusterClient. +/// +/// +/// Fire-and-forget. The Akka path never acks UnsubscribeDebugView, but a unary RPC +/// must answer, so the dispatcher marks it +/// and this service Tells the target then returns the synthetic ack. +/// +/// +/// Ack-before-Leave. TriggerFailover resolves the standby WITHOUT leaving +/// (), returns the ack, and only THEN schedules +/// the real Cluster.Leave — so a caller reaching the very node that is about to leave still +/// receives its ack instead of a broken stream. +/// +/// +public sealed class SiteCommandGrpcService : SiteCommandService.SiteCommandServiceBase +{ + // A local Ask has no client deadline of its own; fall back to a generous ceiling when the + // caller set none (a deadline-less client is a test or an internal caller). When the caller + // DID set a gRPC deadline, honour the remaining time instead. + private static readonly TimeSpan DefaultAskTimeout = TimeSpan.FromMinutes(2); + + private readonly ILogger _logger; + private readonly Action _leaveScheduler; + + // Set once by SetReady after the site actor system and dispatcher exist. Read on Kestrel + // threads, written on the host bring-up thread — volatile. + private volatile SiteCommandDispatcher? _dispatcher; + private volatile bool _ready; + + /// DI constructor. + /// Logger for denial/dispatch diagnostics. + public SiteCommandGrpcService(ILogger logger) + : this(logger, DeferLeaveUntilAfterReply) + { + } + + /// + /// Test constructor letting a test observe when the deferred leave runs. Internal so DI sees a + /// single public constructor. + /// + /// Logger. + /// Runs the deferred Cluster.Leave after the ack is returned. + internal SiteCommandGrpcService(ILogger logger, Action leaveScheduler) + { + ArgumentNullException.ThrowIfNull(logger); + ArgumentNullException.ThrowIfNull(leaveScheduler); + _logger = logger; + _leaveScheduler = leaveScheduler; + } + + /// + /// Marks the service ready and injects the shared routing table. Called once the site actor + /// system, the Deployment Manager singleton, and the local handlers are up — the same point + /// SiteStreamGrpcServer.SetReady is called. + /// + /// The shared dispatcher (the same instance the actor routes through). + public void SetReady(SiteCommandDispatcher dispatcher) + { + ArgumentNullException.ThrowIfNull(dispatcher); + _dispatcher = dispatcher; + _ready = true; + } + + /// Whether the service is accepting commands. Exposed for tests. + internal bool IsReady => _ready; + + /// + public override async Task ExecuteLifecycle(LifecycleRequest request, ServerCallContext context) + { + var command = SiteCommandDtoMapper.FromLifecycleRequest(request); + var reply = await DispatchAsync(command, context).ConfigureAwait(false); + return SiteCommandDtoMapper.ToLifecycleReply(reply); + } + + /// + public override async Task ExecuteOpcUa(OpcUaRequest request, ServerCallContext context) + { + var command = SiteCommandDtoMapper.FromOpcUaRequest(request); + var reply = await DispatchAsync(command, context).ConfigureAwait(false); + return SiteCommandDtoMapper.ToOpcUaReply(reply); + } + + /// + public override async Task ExecuteQuery(QueryRequest request, ServerCallContext context) + { + var command = SiteCommandDtoMapper.FromQueryRequest(request); + var reply = await DispatchAsync(command, context).ConfigureAwait(false); + return SiteCommandDtoMapper.ToQueryReply(reply); + } + + /// + public override async Task ExecuteParked(ParkedRequest request, ServerCallContext context) + { + var command = SiteCommandDtoMapper.FromParkedRequest(request); + var reply = await DispatchAsync(command, context).ConfigureAwait(false); + return SiteCommandDtoMapper.ToParkedReply(reply); + } + + /// + public override async Task ExecuteRoute(RouteRequest request, ServerCallContext context) + { + var command = SiteCommandDtoMapper.FromRouteRequest(request); + var reply = await DispatchAsync(command, context).ConfigureAwait(false); + return SiteCommandDtoMapper.ToRouteReply(reply); + } + + /// + public override Task TriggerFailover(TriggerSiteFailoverDto request, ServerCallContext context) + { + var dispatcher = EnsureReady(); + var msg = SiteCommandDtoMapper.FromProto(request); + + // Resolve the standby and build the ack WITHOUT leaving. The real leave is deferred so the + // ack is on the wire first (ack-before-Leave). + var outcome = dispatcher.PrepareFailover(msg); + var dto = SiteCommandDtoMapper.ToProto(outcome.Ack); + + if (outcome.CommitLeave is not null) + { + _leaveScheduler(outcome.CommitLeave); + } + + return Task.FromResult(dto); + } + + /// + /// Routes a decoded command through the shared dispatcher and returns the reply record. + /// Forward → local Ask; fire-and-forget → local Tell + synthetic ack; no target → + /// the dispatcher's synthetic "handler not available" reply. + /// + private async Task DispatchAsync(object command, ServerCallContext context) + { + var dispatcher = EnsureReady(); + var route = dispatcher.ResolveRoute(command); + + switch (route.Disposition) + { + case SiteCommandDispatcher.RouteDisposition.ImmediateReply: + return route.Reply!; + + case SiteCommandDispatcher.RouteDisposition.TellFireAndForget: + route.Target!.Tell(command); + return route.Reply!; + + default: + try + { + return await route.Target!.Ask( + command, AskTimeout(context), context.CancellationToken).ConfigureAwait(false); + } + catch (RpcException) + { + throw; + } + catch (OperationCanceledException) + { + // Client cancelled or the deadline elapsed. + throw new RpcException(new GrpcStatus( + StatusCode.DeadlineExceeded, "Site did not answer within the deadline.")); + } + catch (Exception ex) + { + _logger.LogWarning(ex, + "Local dispatch of {Command} faulted", command.GetType().Name); + throw new RpcException(new GrpcStatus(StatusCode.Internal, ex.Message)); + } + } + } + + private SiteCommandDispatcher EnsureReady() + { + var dispatcher = _dispatcher; + if (!_ready || dispatcher is null) + { + throw new RpcException(new GrpcStatus( + StatusCode.Unavailable, "Site command plane not ready.")); + } + return dispatcher; + } + + private static TimeSpan AskTimeout(ServerCallContext context) + { + var deadline = context.Deadline; + if (deadline == DateTime.MaxValue) + { + return DefaultAskTimeout; + } + + var remaining = deadline - DateTime.UtcNow; + return remaining > TimeSpan.Zero ? remaining : TimeSpan.FromMilliseconds(1); + } + + // Default deferred-leave: return control (so the ack serialises) before the graceful Leave + // runs. A yield hands the current continuation back before the leave begins; the leave itself + // is slow-async (member marked Leaving, CoordinatedShutdown over seconds), so the ack is long + // gone by the time Kestrel actually stops. + private static void DeferLeaveUntilAfterReply(Action commitLeave) + => _ = Task.Run(async () => + { + await Task.Yield(); + commitLeave(); + }); +} diff --git a/src/ZB.MOM.WW.ScadaBridge.Host/Actors/AkkaHostedService.cs b/src/ZB.MOM.WW.ScadaBridge.Host/Actors/AkkaHostedService.cs index 3624b5ac..7a184ab7 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Host/Actors/AkkaHostedService.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Host/Actors/AkkaHostedService.cs @@ -863,7 +863,20 @@ akka {{ _nodeOptions.SiteId); } - // Create SiteCommunicationActor for receiving messages from central + // The ONE routing table for central→site commands, shared by the Akka + // SiteCommunicationActor (below) and the gRPC SiteCommandGrpcService (SetReady at the end + // of this method). Its failover seam resolves (dryRun) or performs the graceful Leave via + // the shared ClusterFailoverCoordinator so the central/site paths cannot drift. + var siteCommandDispatcher = new SiteCommandDispatcher( + _nodeOptions.SiteId!, + dmProxy, + (role, dryRun) => ZB.MOM.WW.ScadaBridge.Communication.ClusterState.ClusterFailoverCoordinator + .FailOverOldest(_actorSystem!, role, dryRun)?.ToString()); + + // Create SiteCommunicationActor for receiving messages from central. It routes commands + // through the shared dispatcher; RegisterLocalHandler (below) registers the handlers INTO + // that dispatcher, so the gRPC command service sees the same registrations. The site→central + // transport is selected above (default Akka); both are passed in. var siteCommActor = _actorSystem.ActorOf( Props.Create(() => new SiteCommunicationActor( _nodeOptions.SiteId!, @@ -871,7 +884,8 @@ akka {{ dmProxy, activeNodeCheck, null, - centralTransport)), + centralTransport, + siteCommandDispatcher)), "site-communication"); // Register local handlers with SiteCommunicationActor @@ -1141,5 +1155,12 @@ akka {{ grpcServer?.SetOperationTrackingStore(siteTrackingStore); } grpcServer?.SetReady(_actorSystem!); + + // Site command plane: hand the shared dispatcher to the gRPC command service and flip it + // ready at the same point as the streaming server — the actor graph exists and the local + // handlers have been registered into the dispatcher, so it can route commands now. + var commandService = _serviceProvider + .GetService(); + commandService?.SetReady(siteCommandDispatcher); } } diff --git a/src/ZB.MOM.WW.ScadaBridge.Host/ControlPlaneAuthInterceptor.cs b/src/ZB.MOM.WW.ScadaBridge.Host/ControlPlaneAuthInterceptor.cs index 19445447..e0820130 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Host/ControlPlaneAuthInterceptor.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Host/ControlPlaneAuthInterceptor.cs @@ -51,12 +51,18 @@ namespace ZB.MOM.WW.ScadaBridge.Host; public sealed class ControlPlaneAuthInterceptor : Interceptor { /// - /// Service prefixes gated by default. Read from the generated sitestream.proto - /// package/service names — package sitestream; service SiteStreamService. - /// Later phases append their own services here. + /// Service prefixes gated by default. Read from the generated service descriptors — + /// package sitestream; service SiteStreamService (real-time data + audit pull) and + /// package scadabridge.sitecommand.v1; service SiteCommandService (the T1B command + /// plane). Later phases append their own services here rather than adding a second + /// interceptor or constructor (see the public constructor's remarks). /// public static readonly IReadOnlyList DefaultGatedPrefixes = - new[] { "/sitestream.SiteStreamService/" }; + new[] + { + $"/{SiteStreamService.Descriptor.FullName}/", + $"/{SiteCommandService.Descriptor.FullName}/", + }; private readonly IReadOnlyList _gatedPrefixes; private readonly IOptions _options; diff --git a/src/ZB.MOM.WW.ScadaBridge.Host/Program.cs b/src/ZB.MOM.WW.ScadaBridge.Host/Program.cs index bd1a99b1..96a495b4 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Host/Program.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Host/Program.cs @@ -610,6 +610,10 @@ try options.Interceptors.Add(); }); builder.Services.AddSingleton(); + // Site command plane (central→site) — the gRPC peer of SiteStreamGrpcServer. Both share + // the h2c listener and the ControlPlaneAuthInterceptor PSK gate; both are readiness-gated + // (SetReady, below). Central still dials over ClusterClient until T1B.3. + builder.Services.AddSingleton(); // Existing site service registrations (this is also where LocalDb and its // replication engine are registered — see SiteServiceRegistration) @@ -631,6 +635,9 @@ try // Map gRPC service — resolves the singleton SiteStreamGrpcServer from DI app.MapGrpcService(); + // Map the site command plane (central→site) alongside it, on the same gated listener. + app.MapGrpcService(); + // The passive half of LocalDb replication: the peer node dials THIS endpoint. // It shares the Kestrel h2c listener the site gRPC server already uses, so no // listener or port changes are needed. Mapping it is harmless with no peer diff --git a/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/SiteCommandDispatcherTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/SiteCommandDispatcherTests.cs new file mode 100644 index 00000000..fc984581 --- /dev/null +++ b/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/SiteCommandDispatcherTests.cs @@ -0,0 +1,360 @@ +using Akka.Actor; +using Akka.TestKit.Xunit2; +using ZB.MOM.WW.ScadaBridge.Commons.Messages.Artifacts; +using ZB.MOM.WW.ScadaBridge.Commons.Messages.Integration; +using ZB.MOM.WW.ScadaBridge.Commons.Messages.RemoteQuery; +using ZB.MOM.WW.ScadaBridge.Commons.Types; +using ZB.MOM.WW.ScadaBridge.Communication.Actors; +using ZB.MOM.WW.ScadaBridge.Communication.Grpc; + +namespace ZB.MOM.WW.ScadaBridge.Communication.Tests; + +/// +/// The single routing truth for the 28 migrated central→site commands. Proves each command +/// resolves to EXACTLY the target the actor used inline before the extraction — including the +/// node-local parked handler (never the singleton proxy) and the local failover path — so the +/// Akka actor and the gRPC service can share one table without drift. +/// +public class SiteCommandDispatcherTests : TestKit +{ + private const string SiteId = "site1"; + + private SiteCommandDispatcher Build(IActorRef dmProxy, Func? failover = null) + => new(SiteId, dmProxy, failover ?? ((_, _) => null)); + + // ── The 19 commands that forward to the Deployment Manager singleton proxy ── + + /// The proxy-routed commands (lifecycle, OPC UA, debug snapshot/subscribe, route). + public static IEnumerable ProxyCommandTypes() => new[] + { + typeof(Commons.Messages.Deployment.RefreshDeploymentCommand), + typeof(Commons.Messages.Lifecycle.EnableInstanceCommand), + typeof(Commons.Messages.Lifecycle.DisableInstanceCommand), + typeof(Commons.Messages.Lifecycle.DeleteInstanceCommand), + typeof(Commons.Messages.Deployment.DeploymentStateQueryRequest), + typeof(Commons.Messages.Management.BrowseNodeCommand), + typeof(Commons.Messages.Management.SearchAddressSpaceCommand), + typeof(Commons.Messages.Management.ReadTagValuesCommand), + typeof(Commons.Messages.Management.VerifyEndpointCommand), + typeof(Commons.Messages.Management.TrustServerCertCommand), + typeof(Commons.Messages.Management.ListServerCertsCommand), + typeof(Commons.Messages.Management.RemoveServerCertCommand), + typeof(Commons.Messages.DataConnection.WriteTagRequest), + typeof(Commons.Messages.DebugView.DebugSnapshotRequest), + typeof(Commons.Messages.DebugView.SubscribeDebugViewRequest), + typeof(Commons.Messages.InboundApi.RouteToCallRequest), + typeof(Commons.Messages.InboundApi.RouteToGetAttributesRequest), + typeof(Commons.Messages.InboundApi.RouteToSetAttributesRequest), + typeof(Commons.Messages.InboundApi.RouteToWaitForAttributeRequest), + }.Select(t => new object[] { t }); + + [Theory] + [MemberData(nameof(ProxyCommandTypes))] + public void ProxyCommands_ForwardToDeploymentManager(Type commandType) + { + var dm = CreateTestProbe(); + var dispatcher = Build(dm.Ref); + var command = SiteCommandSamples.All[commandType][0]; + + var route = dispatcher.ResolveRoute(command); + + Assert.Equal(SiteCommandDispatcher.RouteDisposition.Forward, route.Disposition); + Assert.Same(dm.Ref, route.Target); + } + + [Fact] + public void UnsubscribeDebugView_IsFireAndForget_ToDeploymentManager_WithSyntheticAck() + { + // Fire-and-forget: the Deployment Manager never acks an unsubscribe. The actor Forwards it; + // the gRPC transport Tells it and returns this synthetic ack so a unary RPC still answers. + var dm = CreateTestProbe(); + var dispatcher = Build(dm.Ref); + var command = SiteCommandSamples.All[typeof(Commons.Messages.DebugView.UnsubscribeDebugViewRequest)][0]; + + var route = dispatcher.ResolveRoute(command); + + Assert.Equal(SiteCommandDispatcher.RouteDisposition.TellFireAndForget, route.Disposition); + Assert.Same(dm.Ref, route.Target); + Assert.Same(UnsubscribeDebugViewAck.Instance, route.Reply); + } + + // ── Artifact handler (null-guarded) ── + + [Fact] + public void DeployArtifacts_WithHandler_ForwardsToArtifactHandler_NotTheProxy() + { + var dm = CreateTestProbe(); + var artifact = CreateTestProbe(); + var dispatcher = Build(dm.Ref); + dispatcher.RegisterArtifactHandler(artifact.Ref); + + var route = dispatcher.ResolveRoute(SiteCommandSamples.All[typeof(DeployArtifactsCommand)][0]); + + Assert.Equal(SiteCommandDispatcher.RouteDisposition.Forward, route.Disposition); + Assert.Same(artifact.Ref, route.Target); + Assert.NotSame(dm.Ref, route.Target); + } + + [Fact] + public void DeployArtifacts_WithoutHandler_RepliesHandlerNotAvailable() + { + var dm = CreateTestProbe(); + var dispatcher = Build(dm.Ref); + var command = (DeployArtifactsCommand)SiteCommandSamples.All[typeof(DeployArtifactsCommand)][0]; + + var route = dispatcher.ResolveRoute(command); + + Assert.Equal(SiteCommandDispatcher.RouteDisposition.ImmediateReply, route.Disposition); + var reply = Assert.IsType(route.Reply); + Assert.False(reply.Success); + Assert.Equal("Artifact handler not available", reply.ErrorMessage); + Assert.Equal(command.DeploymentId, reply.DeploymentId); + Assert.Equal(SiteId, reply.SiteId); + } + + // ── Event-log handler (null-guarded) ── + + [Fact] + public void EventLogQuery_WithHandler_ForwardsToEventLogHandler() + { + var dm = CreateTestProbe(); + var eventLog = CreateTestProbe(); + var dispatcher = Build(dm.Ref); + dispatcher.RegisterEventLogHandler(eventLog.Ref); + + var route = dispatcher.ResolveRoute(SiteCommandSamples.All[typeof(EventLogQueryRequest)][0]); + + Assert.Equal(SiteCommandDispatcher.RouteDisposition.Forward, route.Disposition); + Assert.Same(eventLog.Ref, route.Target); + } + + [Fact] + public void EventLogQuery_WithoutHandler_RepliesHandlerNotAvailable() + { + var dm = CreateTestProbe(); + var dispatcher = Build(dm.Ref); + var command = (EventLogQueryRequest)SiteCommandSamples.All[typeof(EventLogQueryRequest)][0]; + + var route = dispatcher.ResolveRoute(command); + + Assert.Equal(SiteCommandDispatcher.RouteDisposition.ImmediateReply, route.Disposition); + var reply = Assert.IsType(route.Reply); + Assert.False(reply.Success); + Assert.Equal(command.CorrelationId, reply.CorrelationId); + } + + // ── Parked handler (null-guarded) — stays NODE-LOCAL on purpose (replicated store) ── + + /// The five parked commands, each of which routes to the per-node parked handler. + public static IEnumerable ParkedCommandTypes() => new[] + { + typeof(ParkedMessageQueryRequest), + typeof(ParkedMessageRetryRequest), + typeof(ParkedMessageDiscardRequest), + typeof(RetryParkedOperation), + typeof(DiscardParkedOperation), + }.Select(t => new object[] { t }); + + [Theory] + [MemberData(nameof(ParkedCommandTypes))] + public void ParkedCommands_WithHandler_RouteToNodeLocalParkedHandler_NeverTheProxy(Type commandType) + { + // Node-locality proof: a parked retry/discard must run on the node holding the replicated + // store row, so it goes to the per-node parked handler — NEVER the singleton proxy. This is + // the constraint the extraction must not "fix". + var dm = CreateTestProbe(); + var parked = CreateTestProbe(); + var dispatcher = Build(dm.Ref); + dispatcher.RegisterParkedMessageHandler(parked.Ref); + + var route = dispatcher.ResolveRoute(SiteCommandSamples.All[commandType][0]); + + Assert.Equal(SiteCommandDispatcher.RouteDisposition.Forward, route.Disposition); + Assert.Same(parked.Ref, route.Target); + Assert.NotSame(dm.Ref, route.Target); + } + + [Fact] + public void ParkedMessageQuery_WithoutHandler_RepliesHandlerNotAvailable() + { + var dispatcher = Build(CreateTestProbe().Ref); + var command = (ParkedMessageQueryRequest)SiteCommandSamples.All[typeof(ParkedMessageQueryRequest)][0]; + + var route = dispatcher.ResolveRoute(command); + + var reply = Assert.IsType(route.Reply); + Assert.False(reply.Success); + Assert.Equal(command.CorrelationId, reply.CorrelationId); + Assert.Equal(command.PageNumber, reply.PageNumber); + } + + [Fact] + public void ParkedMessageRetry_WithoutHandler_RepliesHandlerNotAvailable() + { + var dispatcher = Build(CreateTestProbe().Ref); + var command = (ParkedMessageRetryRequest)SiteCommandSamples.All[typeof(ParkedMessageRetryRequest)][0]; + + var reply = Assert.IsType(dispatcher.ResolveRoute(command).Reply); + Assert.False(reply.Success); + Assert.Equal(command.CorrelationId, reply.CorrelationId); + } + + [Fact] + public void ParkedMessageDiscard_WithoutHandler_RepliesHandlerNotAvailable() + { + var dispatcher = Build(CreateTestProbe().Ref); + var command = (ParkedMessageDiscardRequest)SiteCommandSamples.All[typeof(ParkedMessageDiscardRequest)][0]; + + var reply = Assert.IsType(dispatcher.ResolveRoute(command).Reply); + Assert.False(reply.Success); + Assert.Equal(command.CorrelationId, reply.CorrelationId); + } + + [Fact] + public void RetryParkedOperation_WithoutHandler_RepliesNotAppliedAck() + { + var dispatcher = Build(CreateTestProbe().Ref); + var command = new RetryParkedOperation("corr-x", TrackedOperationId.New()); + + var reply = Assert.IsType(dispatcher.ResolveRoute(command).Reply); + Assert.False(reply.Applied); + Assert.Equal("corr-x", reply.CorrelationId); + Assert.NotNull(reply.ErrorMessage); + } + + [Fact] + public void DiscardParkedOperation_WithoutHandler_RepliesNotAppliedAck() + { + var dispatcher = Build(CreateTestProbe().Ref); + var command = new DiscardParkedOperation("corr-y", TrackedOperationId.New()); + + var reply = Assert.IsType(dispatcher.ResolveRoute(command).Reply); + Assert.False(reply.Applied); + Assert.Equal("corr-y", reply.CorrelationId); + } + + // ── Commands that must NOT enter ResolveRoute ── + + [Fact] + public void ResolveRoute_RejectsFailover_ItGoesThroughPrepareFailover() + { + var dispatcher = Build(CreateTestProbe().Ref); + Assert.Throws(() => + dispatcher.ResolveRoute(new TriggerSiteFailover("c", SiteId))); + } + + [Fact] + public void ResolveRoute_RejectsTheExcludedIntegrationCommand() + { + // IntegrationCallRequest is the 29th command, dead at both ends and deliberately excluded + // (28 of 29 migrate). It never enters the dispatcher. + var dispatcher = Build(CreateTestProbe().Ref); + var command = new IntegrationCallRequest( + "c", SiteId, "inst", "es", "m", new Dictionary(), DateTimeOffset.UtcNow); + + Assert.Throws(() => dispatcher.ResolveRoute(command)); + } + + // ── Failover (local path) ── + + [Fact] + public void HandleFailover_ResolvesAndLeaves_ThenAcksWithTheTarget() + { + string? roleAsked = null; + var leaveIssued = false; + Func resolve = (role, dryRun) => + { + roleAsked = role; + leaveIssued = !dryRun; + return "akka.tcp://scadabridge@site1-a:8082"; + }; + var dispatcher = Build(CreateTestProbe().Ref, resolve); + + var ack = dispatcher.HandleFailover(new TriggerSiteFailover("corr-1", SiteId)); + + Assert.True(ack.Accepted); + Assert.Equal("corr-1", ack.CorrelationId); + Assert.Equal("akka.tcp://scadabridge@site1-a:8082", ack.TargetAddress); + Assert.Null(ack.ErrorMessage); + // Site singletons are scoped to the site-specific role. + Assert.Equal("site-site1", roleAsked); + // The actor path leaves in one step (dryRun:false). + Assert.True(leaveIssued); + } + + [Fact] + public void HandleFailover_RefusesWhenThereIsNoPeer() + { + var dispatcher = Build(CreateTestProbe().Ref, (_, _) => null); + + var ack = dispatcher.HandleFailover(new TriggerSiteFailover("corr-2", SiteId)); + + Assert.False(ack.Accepted); + Assert.Null(ack.TargetAddress); + Assert.NotNull(ack.ErrorMessage); + } + + [Fact] + public void HandleFailover_RefusesACommandAddressedToAnotherSite_WithoutTouchingTheResolver() + { + var invoked = false; + var dispatcher = Build(CreateTestProbe().Ref, (_, _) => { invoked = true; return "addr"; }); + + var ack = dispatcher.HandleFailover(new TriggerSiteFailover("corr-3", "site2")); + + Assert.False(ack.Accepted); + Assert.Contains("site2", ack.ErrorMessage); + Assert.False(invoked); + } + + [Fact] + public void HandleFailover_FaultInTheLeave_IsReportedNotThrown() + { + var dispatcher = Build(CreateTestProbe().Ref, + (_, _) => throw new InvalidOperationException("cluster unavailable")); + + var ack = dispatcher.HandleFailover(new TriggerSiteFailover("corr-4", SiteId)); + + Assert.False(ack.Accepted); + Assert.Contains("cluster unavailable", ack.ErrorMessage); + } + + [Fact] + public void PrepareFailover_BuildsTheAckFromADryRun_WithoutLeaving_UntilCommitLeaveRuns() + { + // The gRPC ack-before-Leave proof at the routing-truth level: PrepareFailover resolves the + // standby with a DRY-RUN only (so the ack is built without leaving), and hands back a + // deferred CommitLeave. Only invoking CommitLeave — what the gRPC service does AFTER the + // ack is on the wire — performs the real leave. + var events = new List(); + Func resolve = (_, dryRun) => + { + events.Add(dryRun ? "resolve" : "leave"); + return "akka.tcp://scadabridge@site1-a:8082"; + }; + var dispatcher = Build(CreateTestProbe().Ref, resolve); + + var outcome = dispatcher.PrepareFailover(new TriggerSiteFailover("corr-5", SiteId)); + + Assert.True(outcome.Ack.Accepted); + Assert.Equal("akka.tcp://scadabridge@site1-a:8082", outcome.Ack.TargetAddress); + Assert.NotNull(outcome.CommitLeave); + // Building the ack did NOT leave. + Assert.Equal(new[] { "resolve" }, events); + + outcome.CommitLeave!(); + // The leave runs strictly after — the caller sends the ack first. + Assert.Equal(new[] { "resolve", "leave" }, events); + } + + [Fact] + public void PrepareFailover_WhenRefused_HasNoDeferredLeave() + { + var dispatcher = Build(CreateTestProbe().Ref, (_, _) => null); + + var outcome = dispatcher.PrepareFailover(new TriggerSiteFailover("corr-6", SiteId)); + + Assert.False(outcome.Ack.Accepted); + Assert.Null(outcome.CommitLeave); + } +} diff --git a/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/SiteCommandGrpcServiceTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/SiteCommandGrpcServiceTests.cs new file mode 100644 index 00000000..d20683f6 --- /dev/null +++ b/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/SiteCommandGrpcServiceTests.cs @@ -0,0 +1,285 @@ +using Akka.Actor; +using Grpc.Core; +using Grpc.Net.Client; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using ZB.MOM.WW.ScadaBridge.Commons.Messages.DebugView; +using ZB.MOM.WW.ScadaBridge.Commons.Messages.InboundApi; +using ZB.MOM.WW.ScadaBridge.Commons.Messages.Lifecycle; +using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management; +using ZB.MOM.WW.ScadaBridge.Commons.Messages.RemoteQuery; +using ZB.MOM.WW.ScadaBridge.Communication; +using ZB.MOM.WW.ScadaBridge.Communication.Actors; +using ZB.MOM.WW.ScadaBridge.Communication.Grpc; + +namespace ZB.MOM.WW.ScadaBridge.Host.Tests; + +/// +/// The site command plane's gRPC front door (T1B.2): the same PSK gate and readiness convention +/// as SiteStreamGrpcServer, and a decode→dispatch→encode round trip through the ONE +/// for one command in each of the six oneof groups. Runs +/// in-process over — no ports, no containers — with the interceptor +/// registered BY TYPE on AddGrpc (never pre-registered in DI), the shape that keeps +/// Grpc.AspNetCore's own activation in the picture. See +/// for why that matters. +/// +public sealed class SiteCommandGrpcServiceTests : IDisposable +{ + private const string SiteKey = "the-site-a-command-key"; + private const string SiteId = "site-a"; + + private readonly ActorSystem _system = ActorSystem.Create("sitecmd-tests"); + + /// + public void Dispose() => _system.Dispose(); + + /// A dispatcher whose Deployment Manager proxy is a canned-reply responder. + private SiteCommandDispatcher DispatcherWithResponder(out IActorRef responder) + { + responder = _system.ActorOf(Props.Create(() => new Responder())); + var dispatcher = new SiteCommandDispatcher(SiteId, responder, (_, _) => null); + // The parked handler is node-local; point it at the responder too so the Parked group can + // be exercised end-to-end (the null-guard path is covered by the dispatcher unit tests). + dispatcher.RegisterParkedMessageHandler(responder); + return dispatcher; + } + + private SiteCommandGrpcService ReadyService() + { + var service = new SiteCommandGrpcService(NullLogger.Instance); + service.SetReady(DispatcherWithResponder(out _)); + return service; + } + + private static async Task StartHost(SiteCommandGrpcService service) + => await new HostBuilder() + .ConfigureWebHost(web => web + .UseTestServer() + .ConfigureServices(services => + { + // BY TYPE on AddGrpc — never AddSingleton the interceptor (see the sibling test). + services.AddGrpc(o => o.Interceptors.Add()); + services.AddSingleton(Options.Create(new CommunicationOptions { GrpcPsk = SiteKey })); + services.AddSingleton(service); + }) + .Configure(app => + { + app.UseRouting(); + app.UseEndpoints(e => e.MapGrpcService()); + })) + .StartAsync(); + + private static SiteCommandService.SiteCommandServiceClient Client(IHost host, string? key) + { + var server = host.GetTestServer(); + var options = new GrpcChannelOptions { HttpHandler = server.CreateHandler() }; + if (key is not null) + { + options.WithSiteCredentials(new FixedPskProvider(key), SiteId); + } + var channel = GrpcChannel.ForAddress(server.BaseAddress, options); + return new SiteCommandService.SiteCommandServiceClient(channel); + } + + private sealed class FixedPskProvider(string key) : ISitePskProvider + { + public ValueTask GetAsync(string siteId, CancellationToken ct) => new(key); + public void Invalidate(string siteId) { } + } + + // ── Auth gating (delegated to ControlPlaneAuthInterceptor, proven wired here) ── + + [Fact] + public async Task NoCredentials_IsRejected_WithPermissionDenied() + { + using var host = await StartHost(ReadyService()); + var client = Client(host, key: null); + + var ex = await Assert.ThrowsAsync(() => client.ExecuteQueryAsync( + new QueryRequest { UnsubscribeDebugView = new UnsubscribeDebugViewRequestDto() }).ResponseAsync); + + Assert.Equal(StatusCode.PermissionDenied, ex.StatusCode); + } + + [Fact] + public async Task WrongKey_IsRejected_WithPermissionDenied() + { + using var host = await StartHost(ReadyService()); + var client = Client(host, "some-other-key"); + + var ex = await Assert.ThrowsAsync(() => client.ExecuteQueryAsync( + new QueryRequest { UnsubscribeDebugView = new UnsubscribeDebugViewRequestDto() }).ResponseAsync); + + Assert.Equal(StatusCode.PermissionDenied, ex.StatusCode); + } + + // ── Readiness ── + + [Fact] + public async Task BeforeSetReady_IsRejected_WithUnavailable_EvenWithACorrectKey() + { + // Auth passes (correct key); the readiness gate then rejects until the site actor graph is up. + var unready = new SiteCommandGrpcService(NullLogger.Instance); + using var host = await StartHost(unready); + var client = Client(host, SiteKey); + + var ex = await Assert.ThrowsAsync(() => client.ExecuteLifecycleAsync( + new LifecycleRequest { EnableInstance = new EnableInstanceCommandDto { CommandId = "c" } }).ResponseAsync); + + Assert.Equal(StatusCode.Unavailable, ex.StatusCode); + } + + // ── decode → dispatch → encode, one command per oneof group ── + + [Fact] + public async Task ExecuteLifecycle_RoundTripsThroughTheDispatcher() + { + using var host = await StartHost(ReadyService()); + var client = Client(host, SiteKey); + var command = new EnableInstanceCommand("cmd-1", "Site1.Pump1", DateTimeOffset.UtcNow); + + var reply = await client.ExecuteLifecycleAsync( + new LifecycleRequest { EnableInstance = SiteCommandDtoMapper.ToProto(command) }); + + var decoded = Assert.IsType(SiteCommandDtoMapper.FromLifecycleReply(reply)); + Assert.True(decoded.Success); + Assert.Equal("cmd-1", decoded.CommandId); + } + + [Fact] + public async Task ExecuteOpcUa_RoundTripsThroughTheDispatcher() + { + using var host = await StartHost(ReadyService()); + var client = Client(host, SiteKey); + var command = new BrowseNodeCommand("conn", null, null, null); + + var reply = await client.ExecuteOpcUaAsync( + new OpcUaRequest { BrowseNode = SiteCommandDtoMapper.ToProto(command) }); + + Assert.IsType(SiteCommandDtoMapper.FromOpcUaReply(reply)); + } + + [Fact] + public async Task ExecuteQuery_DebugSnapshot_RoundTripsThroughTheDispatcher() + { + using var host = await StartHost(ReadyService()); + var client = Client(host, SiteKey); + var command = new DebugSnapshotRequest("Site1.Pump1", "corr-4"); + + var reply = await client.ExecuteQueryAsync( + new QueryRequest { DebugSnapshot = SiteCommandDtoMapper.ToProto(command) }); + + var decoded = Assert.IsType(SiteCommandDtoMapper.FromQueryReply(reply)); + Assert.Equal("Site1.Pump1", decoded.InstanceUniqueName); + } + + [Fact] + public async Task ExecuteQuery_UnsubscribeDebugView_ReturnsTheFireAndForgetAck() + { + // Fire-and-forget: the service Tells the target and returns the synthetic ack so a unary + // RPC still answers, keeping the caller-visible fire-and-forget semantics. + using var host = await StartHost(ReadyService()); + var client = Client(host, SiteKey); + + var reply = await client.ExecuteQueryAsync(new QueryRequest + { + UnsubscribeDebugView = SiteCommandDtoMapper.ToProto( + new UnsubscribeDebugViewRequest("Site1.Pump1", "corr-6")), + }); + + Assert.Equal(QueryReply.ReplyOneofCase.UnsubscribeDebugView, reply.ReplyCase); + } + + [Fact] + public async Task ExecuteParked_RoundTripsThroughTheNodeLocalHandler() + { + using var host = await StartHost(ReadyService()); + var client = Client(host, SiteKey); + var command = new ParkedMessageQueryRequest("corr-7", SiteId, 2, 25, DateTimeOffset.UtcNow); + + var reply = await client.ExecuteParkedAsync( + new ParkedRequest { ParkedMessageQuery = SiteCommandDtoMapper.ToProto(command) }); + + var decoded = Assert.IsType(SiteCommandDtoMapper.FromParkedReply(reply)); + Assert.True(decoded.Success); + Assert.Equal("corr-7", decoded.CorrelationId); + } + + [Fact] + public async Task ExecuteRoute_RoundTripsThroughTheDispatcher() + { + using var host = await StartHost(ReadyService()); + var client = Client(host, SiteKey); + var command = new RouteToCallRequest( + "corr-12", "Site1.Pump1", "Start", new Dictionary(), DateTimeOffset.UtcNow, null); + + var reply = await client.ExecuteRouteAsync( + new RouteRequest { RouteToCall = SiteCommandDtoMapper.ToProto(command) }); + + var decoded = Assert.IsType(SiteCommandDtoMapper.FromRouteReply(reply)); + Assert.True(decoded.Success); + Assert.Equal("corr-12", decoded.CorrelationId); + } + + // ── Failover: the reply completes before the leave is initiated ── + + [Fact] + public async Task TriggerFailover_ReturnsTheAck_BeforeTheLeaveIsInitiated() + { + // The resolver records the order of its calls: PrepareFailover does a DRY-RUN resolve to + // build the ack (recorded synchronously, before the RPC returns), and the real leave runs + // only on the deferred CommitLeave — so the recorded order is always resolve-then-leave, + // i.e. the ack is on the wire before the node begins leaving. + var events = new List(); + var leaveHappened = new ManualResetEventSlim(false); + Func resolve = (_, dryRun) => + { + lock (events) { events.Add(dryRun ? "resolve" : "leave"); } + if (!dryRun) leaveHappened.Set(); + return "akka.tcp://scadabridge@site-a-node-a:8082"; + }; + + var service = new SiteCommandGrpcService(NullLogger.Instance); + var dispatcher = new SiteCommandDispatcher(SiteId, _system.ActorOf(Props.Create(() => new Responder())), resolve); + service.SetReady(dispatcher); + + using var host = await StartHost(service); + var client = Client(host, SiteKey); + + var reply = await client.TriggerFailoverAsync( + new TriggerSiteFailoverDto { CorrelationId = "corr-16", SiteId = SiteId }); + + Assert.True(reply.Accepted); + Assert.Equal("akka.tcp://scadabridge@site-a-node-a:8082", reply.TargetAddress); + + Assert.True(leaveHappened.Wait(TimeSpan.FromSeconds(5)), "the deferred leave never ran"); + lock (events) + { + Assert.Equal(new[] { "resolve", "leave" }, events); + } + } + + /// Deployment Manager stand-in: replies each command with a canned reply of the right group. + private sealed class Responder : ReceiveActor + { + public Responder() => ReceiveAny(msg => Sender.Tell(ReplyFor(msg))); + + private static object ReplyFor(object m) => m switch + { + EnableInstanceCommand e => + new InstanceLifecycleResponse(e.CommandId, e.InstanceUniqueName, true, null, DateTimeOffset.UtcNow), + BrowseNodeCommand => new BrowseNodeResult([], false, null, null), + DebugSnapshotRequest d => new DebugViewSnapshot(d.InstanceUniqueName, [], [], DateTimeOffset.UtcNow), + RouteToCallRequest r => + new RouteToCallResponse(r.CorrelationId, true, null, null, DateTimeOffset.UtcNow), + ParkedMessageQueryRequest p => new ParkedMessageQueryResponse( + p.CorrelationId, p.SiteId, [], 0, p.PageNumber, p.PageSize, true, null, DateTimeOffset.UtcNow), + _ => new Akka.Actor.Status.Failure(new InvalidOperationException($"no canned reply for {m.GetType().Name}")), + }; + } +}