refactor(host): extract MapOtOpcUaMeshGrpcServices (one authority for the five mesh maps)

Behaviour-preserving: Program.cs:743-784 collapses to a single call, same gates,
same order, every existing per-map rationale comment carried over. A new
endpoint-metadata census (MeshGrpcAnonymousMetadataTests) drives the SAME
production mapper across all three role shapes (admin-only / driver-only /
fused), so the guard can never assert a shape production no longer has -
archreview 03/S14, T1.
This commit is contained in:
Joseph Doherty
2026-08-20 09:42:49 -04:00
parent a011a53329
commit 6dab72a479
3 changed files with 253 additions and 42 deletions
@@ -0,0 +1,97 @@
using ZB.MOM.WW.LocalDb.Replication;
using ZB.MOM.WW.OtOpcUa.AdminUI.Grpc;
using ZB.MOM.WW.OtOpcUa.Host.Grpc;
namespace ZB.MOM.WW.OtOpcUa.Host.Configuration;
/// <summary>
/// Maps the mesh gRPC ingress endpoints (artifact serve, central ack, telemetry stream, node
/// command) and the LocalDb sync endpoint.
/// </summary>
/// <remarks>
/// <para>
/// <b>Do not "tidy" this into Program.cs.</b> A standing census guard
/// (<c>MeshGrpcAnonymousMetadataTests</c>) drives THIS method, so a map that moves back
/// inline leaves the guard asserting a shape production no longer has — which is exactly
/// how the AdminUI cookie <c>FallbackPolicy</c> silently blocked every mesh gRPC call on a
/// login-enabled admin node for seven phases of live gates (archreview 03/S14).
/// </para>
/// </remarks>
public static class MeshGrpcEndpoints
{
/// <summary>
/// Maps the mesh gRPC ingress endpoints and the LocalDb sync endpoint, gated on the same
/// role + dedicated-port checks <c>Program.cs</c> used inline before this extraction.
/// </summary>
/// <param name="app">The built application to map endpoints on.</param>
/// <param name="hasAdmin">Whether this node carries the <c>admin</c> role.</param>
/// <param name="hasDriver">Whether this node carries the <c>driver</c> role.</param>
/// <param name="syncListenPort">
/// The dedicated h2c port bound for the LocalDb passive sync endpoint, or <c>0</c> when it is
/// not bound.
/// </param>
/// <param name="configServeGrpcPort">
/// The dedicated h2c port bound for the ConfigServe artifact + central command endpoints, or
/// <c>0</c> when it is not bound.
/// </param>
/// <param name="telemetryListenPort">
/// The dedicated h2c port bound for the telemetry stream + node command endpoints, or
/// <c>0</c> when it is not bound.
/// </param>
public static void MapOtOpcUaMeshGrpcServices(
this WebApplication app,
bool hasAdmin,
bool hasDriver,
int syncListenPort,
int configServeGrpcPort,
int telemetryListenPort)
{
ArgumentNullException.ThrowIfNull(app);
// Passive LocalDb sync endpoint. Gated on the same port check that bound the listener, so a
// default-OFF or admin-only graph carries no sync surface whatsoever. Mapping it would be
// safe even unauthenticated — LocalDbSyncAuthInterceptor fail-closes with no ApiKey
// configured — but not mapping it at all is the stronger default.
if (hasDriver && syncListenPort > 0)
{
app.MapZbLocalDbSync();
}
// Central config-serve artifact endpoint (per-cluster mesh Phase 3). Mapped only on
// admin-role nodes (which hold the central SQL connection) and only when a dedicated h2c
// port is bound. The ConfigServeAuthInterceptor is the ONLY inbound auth and fail-closes
// with no ConfigServe:ApiKey.
if (hasAdmin && configServeGrpcPort > 0)
{
app.MapGrpcService<DeploymentArtifactGrpcService>();
// Central command ingress (mesh gRPC command plane): the node→central deployment ack. It
// rides the SAME listener rather than claiming a port of its own — a second cleartext h2c
// port would buy nothing but another firewall rule, and both endpoints are admin-role and
// dialled by the same driver nodes. Deliberately NOT gated on MeshTransport:Mode: mapping
// it under Dps costs nothing (nothing dials it) whereas gating it would mean flipping the
// mode on a running fleet requires a central restart before any node can ack — the same
// reasoning that registers CentralCommunicationActor in both modes.
// CentralCommandAuthInterceptor (added at AddGrpc in Program.cs) is the ONLY inbound auth
// and fail-closes per cluster.
app.MapGrpcService<CentralCommandGrpcService>();
}
// Driver-node live-telemetry stream (per-cluster mesh Phase 5). Central dials in; gated by
// TelemetryStreamAuthInterceptor (fail-closed on empty Telemetry:ApiKey).
//
// The node-side command endpoint (mesh gRPC command plane, Phase 1) rides the SAME
// driver-role h2c listener rather than claiming a fourth port: central already dials this
// node here, both directions are per-cluster-keyed, and a separate port would be one more
// thing to open on every site firewall for no isolation gain. Mapping it is what makes
// MeshCommandAuthInterceptor live — it is registered in Program.cs and is a pass-through
// until a call matches this service's method prefix. It fail-closes with no
// MeshTransport:GrpcPsk, so an operator who maps the service without setting a key gets a
// closed endpoint, not an open one.
if (hasDriver && telemetryListenPort > 0)
{
app.MapGrpcService<TelemetryStreamGrpcService>();
app.MapGrpcService<NodeCommandGrpcService>();
}
}
}
+6 -42
View File
@@ -740,48 +740,12 @@ if (hasAdmin)
app.MapOtOpcUaDeployApi(app.Configuration);
}
// Passive LocalDb sync endpoint. Gated on the same port check that bound the listener, so a
// default-OFF or admin-only graph carries no sync surface whatsoever. Mapping it would be safe
// even unauthenticated — LocalDbSyncAuthInterceptor fail-closes with no ApiKey configured — but
// not mapping it at all is the stronger default.
if (hasDriver && syncListenPort > 0)
{
app.MapZbLocalDbSync();
}
// Central config-serve artifact endpoint (per-cluster mesh Phase 3). Mapped only on admin-role
// nodes (which hold the central SQL connection) and only when a dedicated h2c port is bound. The
// ConfigServeAuthInterceptor is the ONLY inbound auth and fail-closes with no ConfigServe:ApiKey.
if (hasAdmin && configServeGrpcPort > 0)
{
app.MapGrpcService<DeploymentArtifactGrpcService>();
// Central command ingress (mesh gRPC command plane): the node→central deployment ack. It
// rides the SAME listener rather than claiming a port of its own — a second cleartext h2c port
// would buy nothing but another firewall rule, and both endpoints are admin-role and dialled by
// the same driver nodes. Deliberately NOT gated on MeshTransport:Mode: mapping it under Dps
// costs nothing (nothing dials it) whereas gating it would mean flipping the mode on a running
// fleet requires a central restart before any node can ack — the same reasoning that registers
// CentralCommunicationActor in both modes. CentralCommandAuthInterceptor (added at AddGrpc
// above) is the ONLY inbound auth and fail-closes per cluster.
app.MapGrpcService<CentralCommandGrpcService>();
}
// Driver-node live-telemetry stream (per-cluster mesh Phase 5). Central dials in; gated by
// TelemetryStreamAuthInterceptor (fail-closed on empty Telemetry:ApiKey).
//
// The node-side command endpoint (mesh gRPC command plane, Phase 1) rides the SAME driver-role
// h2c listener rather than claiming a fourth port: central already dials this node here, both
// directions are per-cluster-keyed, and a separate port would be one more thing to open on every
// site firewall for no isolation gain. Mapping it is what makes MeshCommandAuthInterceptor live —
// it is registered above and is a pass-through until a call matches this service's method prefix.
// It fail-closes with no MeshTransport:GrpcPsk, so an operator who maps the service without setting
// a key gets a closed endpoint, not an open one.
if (hasDriver && telemetryListenPort > 0)
{
app.MapGrpcService<TelemetryStreamGrpcService>();
app.MapGrpcService<NodeCommandGrpcService>();
}
// Mesh gRPC ingress: the LocalDb passive sync endpoint, the ConfigServe artifact + central
// command endpoints, and the telemetry-stream + node-command endpoints. Extracted to
// MeshGrpcEndpoints so a standing census test drives the SAME production mapping (see that
// file's remarks — archreview 03/S14).
app.MapOtOpcUaMeshGrpcServices(
hasAdmin, hasDriver, syncListenPort, configServeGrpcPort, telemetryListenPort);
app.MapOtOpcUaHealth();
app.MapOtOpcUaMetrics();
@@ -0,0 +1,150 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.DependencyInjection;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Host.Configuration;
namespace ZB.MOM.WW.OtOpcUa.Host.IntegrationTests;
/// <summary>
/// R3-01 (T1-T3) — an endpoint-metadata census over <c>MapOtOpcUaMeshGrpcServices</c>, driven in
/// all three role shapes a node can actually carry (admin-only, driver-only, fused).
/// </summary>
/// <remarks>
/// <para>
/// <b>Why this drives the PRODUCTION mapper, not a hand-copied shape.</b> The Critical this
/// plan fixes (archreview 03/S14) survived seven phases of live gates precisely because every
/// prior test built its own pipeline by hand — <c>MeshGrpcBoundaryTests</c>,
/// <c>ConfigServeListenerTests</c> and <c>LocalDbSyncListenerTests</c> all reproduce the
/// author's mental model of the mapping rather than the host's actual composition. This
/// census calls <see cref="MeshGrpcEndpoints.MapOtOpcUaMeshGrpcServices"/> — the SAME method
/// <c>Program.cs</c> calls — so it is structurally impossible for the guard to assert a shape
/// production no longer has.
/// </para>
/// <para>
/// <b>Filters out grpc-dotnet's own catch-all infrastructure.</b> Every <c>MapGrpcService</c>
/// call also registers a per-service "Unimplemented method for X" fallback, and the FIRST
/// such call in an app additionally registers one shared "Unimplemented service" catch-all —
/// neither carries a route pattern beginning with <c>/</c>, and neither ever reaches real
/// logic (they always answer <c>Unimplemented</c> regardless of caller identity), so their
/// authorization metadata carries no security meaning. <see cref="RealMethodEndpoints"/>
/// keeps only the five real, callable RPC methods.
/// </para>
/// <para>
/// <b>Guard-of-the-guard.</b> Every assertion below pins an EXACT expected count and the
/// exact set of route patterns, never just "not empty" — a census that silently found zero
/// endpoints (a typo'd role gate, a helper that never got called) would pass a bare
/// non-empty check trivially. It cannot pass an exact-count-and-set check by accident.
/// </para>
/// </remarks>
public sealed class MeshGrpcAnonymousMetadataTests
{
private const string ArtifactFetch = "/deployment_artifact.v1.DeploymentArtifactService/Fetch";
private const string CentralReportApply = "/mesh_transport.v1.CentralCommandService/ReportApply";
private const string TelemetrySubscribe = "/telemetry.v1.TelemetryStreamService/Subscribe";
private const string NodeExecute = "/mesh_transport.v1.NodeCommandService/Execute";
private const string LocalDbSyncSync = "/localdb_sync.v1.LocalDbSync/Sync";
/// <summary>The five known mesh gRPC method prefixes — the ONLY endpoints this map may anonymise.</summary>
private static readonly string[] AllFivePrefixes =
[LocalDbSyncSync, ArtifactFetch, CentralReportApply, TelemetrySubscribe, NodeExecute];
[Fact]
public void AdminOnly_MapsExactlyTheConfigServeEndpoints()
{
// Admin-only: no driver role, so neither LocalDbSync nor the telemetry/node-command pair are
// mapped even though a port is configured for them below (0 here, but the shape is the point).
var endpoints = RealMethodEndpoints(BuildApp(
hasAdmin: true, hasDriver: false,
syncListenPort: 0, configServeGrpcPort: 4055, telemetryListenPort: 0));
endpoints.Count.ShouldBe(2);
endpoints.Select(RoutePatternOf).ShouldBe([ArtifactFetch, CentralReportApply], ignoreOrder: true);
}
[Fact]
public void DriverOnly_WithNoSyncPort_MapsExactlyTheTelemetryAndNodeCommandEndpoints()
{
var endpoints = RealMethodEndpoints(BuildApp(
hasAdmin: false, hasDriver: true,
syncListenPort: 0, configServeGrpcPort: 0, telemetryListenPort: 4056));
endpoints.Count.ShouldBe(2);
endpoints.Select(RoutePatternOf).ShouldBe([TelemetrySubscribe, NodeExecute], ignoreOrder: true);
}
[Fact]
public void DriverOnly_WithASyncPort_AlsoMapsTheLocalDbSyncEndpoint()
{
var endpoints = RealMethodEndpoints(BuildApp(
hasAdmin: false, hasDriver: true,
syncListenPort: 4054, configServeGrpcPort: 0, telemetryListenPort: 4056));
endpoints.Count.ShouldBe(3);
endpoints.Select(RoutePatternOf).ShouldBe(
[LocalDbSyncSync, TelemetrySubscribe, NodeExecute], ignoreOrder: true);
}
[Fact]
public void Fused_WithAllThreePorts_MapsAllFiveEndpoints()
{
// The docker-dev central pair's shape (admin,driver,cluster-MAIN) once all three dedicated
// h2c ports are configured — the shape 03/S14 called out as breaking the deploy plane for
// the whole fleet, not only the four sites.
var endpoints = RealMethodEndpoints(BuildApp(
hasAdmin: true, hasDriver: true,
syncListenPort: 4054, configServeGrpcPort: 4055, telemetryListenPort: 4056));
endpoints.Count.ShouldBe(5);
endpoints.Select(RoutePatternOf).ShouldBe(AllFivePrefixes, ignoreOrder: true);
}
[Fact]
public void NeitherRoleNorAnyPort_MapsNothing()
{
// The default-OFF shape: every dedicated port is 0. Nothing about the mesh gRPC surface
// should exist on a node that configured none of it.
var endpoints = RealMethodEndpoints(BuildApp(
hasAdmin: false, hasDriver: false,
syncListenPort: 0, configServeGrpcPort: 0, telemetryListenPort: 0));
endpoints.ShouldBeEmpty();
}
// ---------------------------------------------------------------------------------------
// harness
// ---------------------------------------------------------------------------------------
private static WebApplication BuildApp(
bool hasAdmin,
bool hasDriver,
int syncListenPort,
int configServeGrpcPort,
int telemetryListenPort)
{
var builder = WebApplication.CreateBuilder();
builder.Environment.ApplicationName = typeof(MeshGrpcAnonymousMetadataTests).Assembly.GetName().Name!;
builder.Services.AddGrpc();
var app = builder.Build();
app.MapOtOpcUaMeshGrpcServices(
hasAdmin, hasDriver, syncListenPort, configServeGrpcPort, telemetryListenPort);
return app;
}
/// <summary>
/// The real, callable RPC method endpoints — excludes grpc-dotnet's per-app "Unimplemented
/// service" catch-all and per-service "Unimplemented method for X" fallback (see class
/// remarks). Both carry a route pattern that never starts with <c>/</c>, which is what this
/// filters on.
/// </summary>
private static List<RouteEndpoint> RealMethodEndpoints(WebApplication app) =>
((IEndpointRouteBuilder)app).DataSources
.SelectMany(d => d.Endpoints)
.OfType<RouteEndpoint>()
.Where(e => e.RoutePattern.RawText?.StartsWith('/') == true)
.ToList();
private static string RoutePatternOf(RouteEndpoint e) => e.RoutePattern.RawText!;
}