780bb9c369
Central now ALSO listens for the seven site→central control messages over gRPC, alongside the existing ClusterClient path. Nothing flips to gRPC yet — sites keep CentralTransport=Akka (T1A.3's job); central simply starts also accepting. - CentralControlGrpcService (Communication.Grpc): decodes each RPC onto the SAME in-process message the ClusterClient path carries, Asks the existing CentralCommunicationActor (zero handler-logic changes), encodes the reply via the T1A.1 mapper. Readiness-gated like SiteStreamGrpcServer.SetReady — Unavailable until AkkaHostedService hands the actor over. Heartbeat stays fire-and-forget (Tell, always-OK, never gated on readiness). Ingest reuses the shared SiteStreamGrpcServer.AuditIngestAskTimeout constant. Fault→status mapping is retry-aware: Unavailable (never dispatched, safe to cross-node retry) vs DeadlineExceeded/Internal (it ran, do not re-send elsewhere). - CentralControlAuthInterceptor (Host): a SEPARATE interceptor class, not a variant constructor on ControlPlaneAuthInterceptor. Central's model is per-site (verify the Bearer token against the key for the site in the required x-scadabridge-site header, via ISitePskProvider) where a site verifies its one own-key — a genuinely different model. Fail-closed on every branch: missing or blank header, unresolvable key, and mismatched token all → PermissionDenied, never pass-through. One public constructor only (the explicit-prefix ctor is internal), pinned by a reflection test — a second public ctor makes Grpc.AspNetCore's GetFactory() throw per-call and silently disables the gate. - Explicit Kestrel h2c listener on new option ScadaBridge:Node:CentralGrpcPort (default 8083, symmetric with sites), mirroring the Site branch. Additive to central's :5000 HTTP/1 surface, which is untouched — gRPC does NOT go through Traefik (HTTP/1 only). Registered by type on AddGrpc; service mapped with MapGrpcService. Port range-validated by NodeOptionsValidator. - Rig: publish the central gRPC port 9013:8083 / 9014:8083 on both central nodes so a later task can exercise it. Tests: CentralControlEndToEndTests (Host.Tests, TestServer + real interceptor + real service over a stub actor) proves auth positives/negatives are distinguishable and covers unary + the ingest bridge shapes; the interceptor is registered BY TYPE, never in DI. CentralControlAuthInterceptorTests pins the per-site gate + one-public-ctor invariant. CentralControlGrpcServiceTests (Communication.Tests, TestKit) covers the readiness gate, fire-and-forget heartbeat, and the DeadlineExceeded-vs-Unavailable status mapping. No active <Protobuf> item. Communication.Tests (356) + Host.Tests (384) green.
36 lines
1.7 KiB
C#
36 lines
1.7 KiB
C#
using ZB.MOM.WW.Configuration;
|
|
|
|
namespace ZB.MOM.WW.ScadaBridge.Host;
|
|
|
|
/// <summary>
|
|
/// Validates <see cref="NodeOptions"/> at host startup (arch-review 08 round 2
|
|
/// NF4). The headline invariant: <see cref="NodeOptions.NodeName"/> must be
|
|
/// non-empty — an empty value normalises to a NULL <c>SourceNode</c> audit
|
|
/// column, silently degrading the audit trail (the wonder-app-vd03 failure
|
|
/// mode), so the host must fail fast at boot instead. Ports are bounded to the
|
|
/// TCP range; <c>0</c> stays valid because it requests a dynamically-assigned
|
|
/// port (CompositionRootTests rely on this).
|
|
/// </summary>
|
|
public sealed class NodeOptionsValidator : OptionsValidatorBase<NodeOptions>
|
|
{
|
|
/// <inheritdoc />
|
|
protected override void Validate(ValidationBuilder builder, NodeOptions options)
|
|
{
|
|
builder.RequireThat(!string.IsNullOrWhiteSpace(options.NodeName),
|
|
$"ScadaBridge:Node:{nameof(NodeOptions.NodeName)} must be a non-empty label " +
|
|
"— an empty node name stamps the SourceNode audit column NULL.");
|
|
|
|
RequirePort(builder, options.RemotingPort, nameof(NodeOptions.RemotingPort));
|
|
RequirePort(builder, options.GrpcPort, nameof(NodeOptions.GrpcPort));
|
|
RequirePort(builder, options.MetricsPort, nameof(NodeOptions.MetricsPort));
|
|
RequirePort(builder, options.CentralGrpcPort, nameof(NodeOptions.CentralGrpcPort));
|
|
}
|
|
|
|
// 0 stays valid (dynamic-port request); reject only out-of-TCP-range values.
|
|
private static void RequirePort(ValidationBuilder builder, int port, string field)
|
|
{
|
|
builder.RequireThat(port is >= 0 and <= 65_535,
|
|
$"ScadaBridge:Node:{field} ({port}) must be in [0, 65535].");
|
|
}
|
|
}
|