2ee84af1c0
Phase 0 of the ClusterClient→gRPC migration
(docs/plans/2026-07-22-clusterclient-to-grpc-plan.md). Standalone hardening: it
closes a gap that exists today and is a precondition for moving command/control
onto gRPC in later phases.
T0.1 — delete the ManagementActor ClusterClientReceptionist registration.
It was built for an out-of-cluster CLI that was never written: the shipped CLI
speaks HTTP Basic to /management, which asks the actor in-process through
ManagementActorHolder. Nothing in the repo ever sent to /user/management. The
actor still runs there; only the cross-boundary advertisement is gone. Six
documents claimed the CLI used ClusterClient — including the CLI's own README
"Architecture Notes" — and are corrected here rather than left to rot.
T0.2 — record, do not port, the dead integration-routing path.
IntegrationCallRequest is unwired at BOTH ends: RouteIntegrationCallAsync has
zero callers anywhere, and RegisterLocalHandler(Integration, …) appears only in
a test, so production always answers "Integration handler not available". It is
excluded from the gRPC contract (28 of 29 commands migrate) rather than
enshrined on an additive-only wire format, and deleting it during a
transport migration would mix a behavioural change into a change whose whole
value is that behaviour is identical. See
docs/known-issues/2026-07-22-integration-call-routing-is-dead-code.md.
T0.3 — preshared-key authentication on SiteStreamService.
The service shipped with no auth at all: plaintext h2c, no interceptor, so
anything that could reach a site node's :8083 could open a live data stream or
read audit rows back via PullAuditEvents/PullSiteCalls. ControlPlaneAuthInterceptor
now gates /sitestream.SiteStreamService/ — modeled on LocalDbSyncAuthInterceptor
(constant-time compare, fail-closed, PermissionDenied) but gating a SET of
service prefixes so phases 1A/1B add services rather than interceptors. LocalDb
sync keeps its own separate key: it authenticates the pair partner, not central,
and collapsing the two would make a site's central-facing key also admit writes
into its database.
Keys are per site (SB-GRPC-PSK-<siteId>), never fleet-wide, so a compromised
site yields only its own. Central attaches them through ControlPlaneCredentials,
which binds CallCredentials to the channel — covering unary and streaming
uniformly, and letting the key resolve asynchronously, which a client
interceptor could not do without blocking. All three central→site channel
creation sites go through it (SiteStreamGrpcClient and both audit pull invokers);
the pull invokers' channel caches are re-keyed by (site, endpoint) because
credentials are per-site and bound to the channel.
Two decisions beyond the plan:
* StartupValidator now requires GrpcPsk on Site nodes. The plan specified only
the runtime gate, but fail-closed with no boot check produces a node that
joins, answers heartbeats and reports healthy while refusing every stream,
audit pull and telemetry ingest — silent and total. Same reasoning as the
existing inbound API-key pepper rule.
* Added Communication:SitePsks as a central-side key map. The plan assumed
central would read the store, seeded via a dev KEK; the docker rig
deliberately boots with no master key, so store-only resolution would leave
it unable to dial its own sites. The store stays primary — it is the only
source that can serve a site added at runtime — with the map covering
key-less hosts and one-off pins. Neither source falling back to
"unauthenticated" is the invariant.
T0.4 — dev keys on both rigs and tests.
34 tests. The seven that matter most exercise a real in-process gRPC stack over
TestServer: the unit tests on either side of the wire would both stay green if
the halves disagreed, and gRPC refuses call credentials on a plaintext channel
by default — the UnsafeUseInsecureChannelCallCredentials opt-in is only provable
by making a real call. They confirm correct key passes on unary AND streaming,
wrong key and no-credentials both get PermissionDenied, and an unresolvable key
fails the call with nothing reaching the service.
OPERATIONAL: a site node upgraded to this build without a key will not boot.
That includes the gitignored deploy/wonder-app-vd03/ overlay.
371 lines
17 KiB
C#
371 lines
17 KiB
C#
using System.Collections.Concurrent;
|
|
using Google.Protobuf.WellKnownTypes;
|
|
using Grpc.Core;
|
|
using Grpc.Net.Client;
|
|
using Microsoft.Extensions.Logging;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
|
|
using ZB.MOM.WW.ScadaBridge.Communication;
|
|
using ZB.MOM.WW.ScadaBridge.Communication.Grpc;
|
|
using ProtoPullRequest = ZB.MOM.WW.ScadaBridge.Communication.Grpc.PullSiteCallsRequest;
|
|
using ProtoPullResponse = ZB.MOM.WW.ScadaBridge.Communication.Grpc.PullSiteCallsResponse;
|
|
using PullSiteCallsResponse = ZB.MOM.WW.ScadaBridge.Commons.Messages.Integration.PullSiteCallsResponse;
|
|
|
|
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Central;
|
|
|
|
/// <summary>
|
|
/// Production <see cref="IPullSiteCallsClient"/> (Site Call Audit) that the
|
|
/// central reconciliation tick (a separate follow-up component) uses to pull the
|
|
/// next batch of cached-call operational rows from a site over the
|
|
/// <c>PullSiteCalls</c> unary gRPC RPC served by <c>SiteStreamGrpcServer</c>.
|
|
/// A near-exact sibling of <see cref="GrpcPullAuditEventsClient"/>.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// <b>Endpoint resolution.</b> The caller passes only a <c>siteId</c>; this
|
|
/// client resolves it to a gRPC authority via <see cref="ISiteEnumerator"/>
|
|
/// (<see cref="SiteEntry.GrpcEndpoint"/>) on every call so a NodeA→NodeB
|
|
/// failover flip or an edited site address takes effect on the next tick. A site
|
|
/// with no registered endpoint yields an empty response (no dial).
|
|
/// </para>
|
|
/// <para>
|
|
/// <b>SourceSite re-stamp.</b> The site leaves
|
|
/// <c>SiteCallOperationalDto.SourceSite</c> empty (the tracking store has no
|
|
/// site-id column). This client is the authority that knows which site it
|
|
/// dialed, so it re-stamps the mapped <see cref="SiteCall.SourceSite"/> from
|
|
/// <c>siteId</c> — the same "re-stamp from the forwarder's own id" pattern the
|
|
/// site push path uses.
|
|
/// </para>
|
|
/// <para>
|
|
/// <b>Fault tolerance.</b> Per the <see cref="IPullSiteCallsClient"/> contract,
|
|
/// tolerable transport faults (<see cref="StatusCode.Unavailable"/>,
|
|
/// <see cref="StatusCode.DeadlineExceeded"/>, <see cref="StatusCode.Cancelled"/>,
|
|
/// bare <see cref="HttpRequestException"/> / <c>SocketException</c>) are caught
|
|
/// and collapsed to an empty response so one offline site never sinks the rest
|
|
/// of the reconciliation tick. Any other transport/protocol fault is also
|
|
/// swallowed to empty: reconciliation is best-effort. Per-row DTO mapping faults
|
|
/// (e.g. a single unparseable <c>TrackedOperationId</c>) are narrower still —
|
|
/// the offending row is skipped+logged and the rest of the batch is returned.
|
|
/// </para>
|
|
/// <para>
|
|
/// <b>Testability.</b> The unary call is reached through the
|
|
/// <see cref="IPullSiteCallsInvoker"/> seam. Production binds
|
|
/// <see cref="GrpcPullSiteCallsInvoker"/> (one cached <see cref="GrpcChannel"/>
|
|
/// per endpoint, keepalive from <see cref="CommunicationOptions"/>); unit tests
|
|
/// inject a fake invoker so no real HTTP/2 endpoint is required.
|
|
/// </para>
|
|
/// </remarks>
|
|
public sealed class GrpcPullSiteCallsClient : IPullSiteCallsClient
|
|
{
|
|
private readonly ISiteEnumerator _sites;
|
|
private readonly IPullSiteCallsInvoker _invoker;
|
|
private readonly ILogger<GrpcPullSiteCallsClient> _logger;
|
|
|
|
/// <summary>
|
|
/// Creates the client over the given site enumerator and unary-call invoker.
|
|
/// </summary>
|
|
/// <param name="sites">Resolves a <c>siteId</c> to its gRPC endpoint.</param>
|
|
/// <param name="invoker">Seam that issues the <c>PullSiteCalls</c> unary RPC against a resolved endpoint.</param>
|
|
/// <param name="logger">Logger for transport-fault diagnostics.</param>
|
|
public GrpcPullSiteCallsClient(
|
|
ISiteEnumerator sites,
|
|
IPullSiteCallsInvoker invoker,
|
|
ILogger<GrpcPullSiteCallsClient> logger)
|
|
{
|
|
_sites = sites ?? throw new ArgumentNullException(nameof(sites));
|
|
_invoker = invoker ?? throw new ArgumentNullException(nameof(invoker));
|
|
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task<PullSiteCallsResponse> PullAsync(
|
|
string siteId,
|
|
DateTime sinceUtc,
|
|
string? afterId,
|
|
int batchSize,
|
|
CancellationToken ct)
|
|
{
|
|
var (endpoint, fallback) = await ResolveEndpointsAsync(siteId, ct).ConfigureAwait(false);
|
|
if (endpoint is null)
|
|
{
|
|
// No gRPC address registered for the site — a configuration decision
|
|
// (mirrors ISiteEnumerator's own contract), not a runtime error, so
|
|
// there is simply nothing to pull.
|
|
_logger.LogDebug(
|
|
"PullSiteCalls skipped: no gRPC endpoint registered for site {SiteId}.", siteId);
|
|
return Empty;
|
|
}
|
|
|
|
var request = new ProtoPullRequest
|
|
{
|
|
// ReadChangedSinceAsync treats DateTime.MinValue as "from the start";
|
|
// EnsureUtc keeps Timestamp.FromDateTime happy (it requires UTC kind).
|
|
SinceUtc = Timestamp.FromDateTime(EnsureUtc(sinceUtc)),
|
|
BatchSize = batchSize,
|
|
// Composite-keyset tiebreak. proto3 has no nullable string —
|
|
// an unset/empty AfterId is the site's signal to keep the legacy
|
|
// inclusive-timestamp contract (also what a first pull sends).
|
|
AfterId = afterId ?? string.Empty,
|
|
};
|
|
|
|
var (reply, transportFault) = await TryInvokeAsync(endpoint, request, siteId, ct)
|
|
.ConfigureAwait(false);
|
|
|
|
// NodeB failover: a transport fault against the primary (NodeA)
|
|
// dials the fallback (NodeB) ONCE before collapsing to empty, so a NodeA
|
|
// outage doesn't take the reconciliation loss-recovery net offline. Only
|
|
// the transport-fault set triggers this (not a mapping/unexpected fault),
|
|
// and not when the caller's token is already cancelled (host shutdown).
|
|
if (reply is null && transportFault
|
|
&& !string.IsNullOrWhiteSpace(fallback)
|
|
&& !string.Equals(fallback, endpoint, StringComparison.Ordinal)
|
|
&& !ct.IsCancellationRequested)
|
|
{
|
|
_logger.LogInformation(
|
|
"PullSiteCalls failing over from primary {Primary} to NodeB {Fallback} for site {SiteId}.",
|
|
endpoint, fallback, siteId);
|
|
(reply, _) = await TryInvokeAsync(fallback, request, siteId, ct).ConfigureAwait(false);
|
|
}
|
|
|
|
if (reply is null)
|
|
{
|
|
return Empty;
|
|
}
|
|
|
|
// Map proto DTOs to central SiteCall entities PER-ROW so one malformed
|
|
// operational (e.g. an unparseable TrackedOperationId) is skipped+logged
|
|
// rather than sinking the whole batch through the outer catch-all. Each
|
|
// survivor is re-stamped with SourceSite from the dialed siteId (the site
|
|
// leaves it empty).
|
|
var siteCalls = new List<SiteCall>(reply.Operationals.Count);
|
|
foreach (var dto in reply.Operationals)
|
|
{
|
|
try
|
|
{
|
|
var sc = SiteCallDtoMapper.FromDto(dto) with { SourceSite = siteId };
|
|
siteCalls.Add(sc);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogWarning(ex,
|
|
"PullSiteCalls dropped a malformed operational row from site {SiteId} (id='{Id}'); continuing with the rest of the batch.",
|
|
siteId, dto.TrackedOperationId);
|
|
}
|
|
}
|
|
|
|
// Order oldest-first by UpdatedAtUtc (the wire is already ordered by the
|
|
// site read, but the contract is explicit, so sort defensively).
|
|
siteCalls.Sort((a, b) => a.UpdatedAtUtc.CompareTo(b.UpdatedAtUtc));
|
|
|
|
return new PullSiteCallsResponse(siteCalls, reply.MoreAvailable);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Issues one pull against <paramref name="endpoint"/>, classifying faults.
|
|
/// Returns <c>(reply, false)</c> on success; <c>(null, true)</c> on a
|
|
/// tolerable TRANSPORT fault (the failover-eligible set:
|
|
/// <see cref="StatusCode.Unavailable"/> / <see cref="StatusCode.DeadlineExceeded"/> /
|
|
/// <see cref="StatusCode.Cancelled"/> / <see cref="HttpRequestException"/> /
|
|
/// <c>SocketException</c> / <see cref="OperationCanceledException"/>); and
|
|
/// <c>(null, false)</c> on any other (mapping/unexpected) fault, which
|
|
/// collapses to empty WITHOUT a fallback dial.
|
|
/// </summary>
|
|
private async Task<(ProtoPullResponse? Reply, bool TransportFault)> TryInvokeAsync(
|
|
string endpoint, ProtoPullRequest request, string siteId, CancellationToken ct)
|
|
{
|
|
try
|
|
{
|
|
var reply = await _invoker.InvokeAsync(siteId, endpoint, request, ct).ConfigureAwait(false);
|
|
return (reply, false);
|
|
}
|
|
catch (RpcException ex) when (IsTolerable(ex.StatusCode))
|
|
{
|
|
_logger.LogDebug(ex,
|
|
"PullSiteCalls tolerable transport fault for site {SiteId} ({Endpoint}): {Status}.",
|
|
siteId, endpoint, ex.StatusCode);
|
|
return (null, true);
|
|
}
|
|
catch (Exception ex) when (ex is HttpRequestException or System.Net.Sockets.SocketException)
|
|
{
|
|
_logger.LogDebug(ex,
|
|
"PullSiteCalls connection-layer fault for site {SiteId} ({Endpoint}).",
|
|
siteId, endpoint);
|
|
return (null, true);
|
|
}
|
|
catch (OperationCanceledException)
|
|
{
|
|
// Reconciliation tick cancelled — caller token (host shutdown) or an
|
|
// internal gRPC deadline / linked-CTS cancellation. Tolerable for a
|
|
// best-effort pull.
|
|
return (null, true);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
// Any other fault. Reconciliation is best-effort; swallow to empty
|
|
// rather than throw — and do NOT fail over, since a second node would
|
|
// hit the same non-transport fault.
|
|
_logger.LogWarning(ex,
|
|
"PullSiteCalls unexpected fault for site {SiteId} ({Endpoint}). Returning empty batch.",
|
|
siteId, endpoint);
|
|
return (null, false);
|
|
}
|
|
}
|
|
|
|
private async Task<(string? Primary, string? Fallback)> ResolveEndpointsAsync(
|
|
string siteId, CancellationToken ct)
|
|
{
|
|
var sites = await _sites.EnumerateAsync(ct).ConfigureAwait(false);
|
|
foreach (var site in sites)
|
|
{
|
|
if (string.Equals(site.SiteId, siteId, StringComparison.Ordinal) &&
|
|
!string.IsNullOrWhiteSpace(site.GrpcEndpoint))
|
|
{
|
|
return (site.GrpcEndpoint, site.FallbackGrpcEndpoint);
|
|
}
|
|
}
|
|
return (null, null);
|
|
}
|
|
|
|
private static readonly PullSiteCallsResponse Empty =
|
|
new(Array.Empty<SiteCall>(), MoreAvailable: false);
|
|
|
|
private static bool IsTolerable(StatusCode code) => code is
|
|
StatusCode.Unavailable or
|
|
StatusCode.DeadlineExceeded or
|
|
StatusCode.Cancelled;
|
|
|
|
// All ScadaBridge timestamps are UTC by invariant. A non-UTC cursor (the
|
|
// reconciliation cursor starts at DateTime.MinValue, Kind=Unspecified) is
|
|
// treated AS UTC — never ToUniversalTime()-converted: on a host with a
|
|
// positive UTC offset MinValue.ToUniversalTime() underflows and
|
|
// Timestamp.FromDateTime throws, crashing the first pull for every site.
|
|
private static DateTime EnsureUtc(DateTime value) =>
|
|
value.Kind == DateTimeKind.Utc ? value : DateTime.SpecifyKind(value, DateTimeKind.Utc);
|
|
|
|
/// <summary>
|
|
/// Seam over the <c>PullSiteCalls</c> unary gRPC call against a resolved site
|
|
/// endpoint. Extracted so <see cref="GrpcPullSiteCallsClient"/> can be
|
|
/// unit-tested without a real <see cref="GrpcChannel"/>. Production binds
|
|
/// <see cref="GrpcPullSiteCallsInvoker"/>.
|
|
/// </summary>
|
|
public interface IPullSiteCallsInvoker
|
|
{
|
|
/// <summary>
|
|
/// Issues the <c>PullSiteCalls</c> unary RPC against <paramref name="endpoint"/>.
|
|
/// May throw <see cref="RpcException"/> / <see cref="HttpRequestException"/>
|
|
/// on transport faults — the caller classifies and swallows tolerable ones.
|
|
/// </summary>
|
|
/// <param name="siteId">
|
|
/// The site being pulled from. Selects which preshared key the call presents —
|
|
/// <c>PullSiteCalls</c> is gated by the site's <c>ControlPlaneAuthInterceptor</c>, and
|
|
/// keys are per-site, so the endpoint alone is not enough to authenticate.
|
|
/// </param>
|
|
/// <param name="endpoint">The site gRPC authority (e.g. <c>http://site-a:8083</c>).</param>
|
|
/// <param name="request">The wire-format pull request.</param>
|
|
/// <param name="ct">Cancellation token.</param>
|
|
/// <returns>The wire-format pull response.</returns>
|
|
Task<ProtoPullResponse> InvokeAsync(
|
|
string siteId, string endpoint, ProtoPullRequest request, CancellationToken ct);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Production <see cref="GrpcPullSiteCallsClient.IPullSiteCallsInvoker"/>: caches
|
|
/// one <see cref="GrpcChannel"/> per endpoint (keepalive from
|
|
/// <see cref="CommunicationOptions"/>, mirroring <c>SiteStreamGrpcClient</c>) and
|
|
/// issues the unary <c>PullSiteCallsAsync</c> call. The cache is keyed by
|
|
/// endpoint string, so a changed site address (NodeA→NodeB failover flip / an
|
|
/// edited gRPC address) is reached as soon as the resolver hands the new endpoint
|
|
/// to <see cref="InvokeAsync"/>. The channel for a previous address lingers idle
|
|
/// until <see cref="Dispose"/> (idle channels hold no streams — a minor cache
|
|
/// footprint cost, not a correctness or liveness gap). Sibling of
|
|
/// <see cref="GrpcPullAuditEventsInvoker"/>.
|
|
/// </summary>
|
|
public sealed class GrpcPullSiteCallsInvoker
|
|
: GrpcPullSiteCallsClient.IPullSiteCallsInvoker, IDisposable
|
|
{
|
|
private readonly ConcurrentDictionary<(string Site, string Endpoint), GrpcChannel> _channels = new();
|
|
private readonly CommunicationOptions _options;
|
|
private readonly ISitePskProvider? _pskProvider;
|
|
|
|
/// <summary>Creates the invoker using default <see cref="CommunicationOptions"/>.</summary>
|
|
public GrpcPullSiteCallsInvoker()
|
|
: this(new CommunicationOptions())
|
|
{
|
|
}
|
|
|
|
/// <summary>
|
|
/// Creates the invoker, applying the configured gRPC keepalive settings to
|
|
/// every channel it opens.
|
|
/// </summary>
|
|
/// <param name="options">Communication options supplying gRPC keepalive timings.</param>
|
|
public GrpcPullSiteCallsInvoker(CommunicationOptions options)
|
|
: this(options, pskProvider: null)
|
|
{
|
|
}
|
|
|
|
/// <summary>
|
|
/// Creates the invoker with per-site call credentials, the production shape: the site's
|
|
/// <c>ControlPlaneAuthInterceptor</c> refuses an unauthenticated <c>PullSiteCalls</c>.
|
|
/// </summary>
|
|
/// <param name="options">Communication options supplying gRPC keepalive timings.</param>
|
|
/// <param name="pskProvider">Resolves each site's preshared key; null dials unauthenticated.</param>
|
|
public GrpcPullSiteCallsInvoker(CommunicationOptions options, ISitePskProvider? pskProvider)
|
|
{
|
|
_options = options ?? throw new ArgumentNullException(nameof(options));
|
|
_pskProvider = pskProvider;
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task<ProtoPullResponse> InvokeAsync(
|
|
string siteId, string endpoint, ProtoPullRequest request, CancellationToken ct)
|
|
{
|
|
var channel = GetOrCreateChannel(siteId, endpoint);
|
|
var client = new SiteStreamService.SiteStreamServiceClient(channel);
|
|
using var call = client.PullSiteCallsAsync(request, cancellationToken: ct);
|
|
return await call.ResponseAsync.ConfigureAwait(false);
|
|
}
|
|
|
|
// Race-safe channel cache (create-then-GetOrAdd-then-dispose-if-lost): two
|
|
// concurrent first dials of the same endpoint can both build a GrpcChannel;
|
|
// only the channel actually installed survives, the loser is disposed.
|
|
// Mirrors SiteStreamGrpcClientFactory / GrpcPullAuditEventsInvoker.
|
|
private GrpcChannel GetOrCreateChannel(string siteId, string endpoint)
|
|
{
|
|
var key = (siteId, endpoint);
|
|
if (!_channels.TryGetValue(key, out var channel))
|
|
{
|
|
var created = CreateChannel(siteId, endpoint);
|
|
channel = _channels.GetOrAdd(key, created);
|
|
if (!ReferenceEquals(channel, created))
|
|
{
|
|
created.Dispose();
|
|
}
|
|
}
|
|
return channel;
|
|
}
|
|
|
|
// Keyed by (site, endpoint) rather than endpoint alone: the call credentials are bound to
|
|
// the channel, and they are per-site, so two sites sharing an endpoint string would
|
|
// otherwise share one channel carrying the first site's key.
|
|
private GrpcChannel CreateChannel(string siteId, string endpoint) =>
|
|
GrpcChannel.ForAddress(endpoint, new GrpcChannelOptions
|
|
{
|
|
HttpHandler = new SocketsHttpHandler
|
|
{
|
|
KeepAlivePingDelay = _options.GrpcKeepAlivePingDelay,
|
|
KeepAlivePingTimeout = _options.GrpcKeepAlivePingTimeout,
|
|
KeepAlivePingPolicy = HttpKeepAlivePingPolicy.Always,
|
|
},
|
|
}.WithSiteCredentials(_pskProvider, siteId));
|
|
|
|
/// <summary>Disposes all cached channels.</summary>
|
|
public void Dispose()
|
|
{
|
|
foreach (var channel in _channels.Values)
|
|
{
|
|
channel.Dispose();
|
|
}
|
|
_channels.Clear();
|
|
}
|
|
}
|