feat(secrets): bump ZB.MOM.WW.Secrets family to 0.5.0 and wire hub fallback endpoints
Pin all five ZB.MOM.WW.Secrets* packages 0.4.1 -> 0.5.0, which brings SecretsGrpcHubClientOptions.FallbackEndpoints and the package's internal FailoverSecretsHubReader. A site whose GrpcHub section lists fallback endpoints now fails a sweep over to the next central instead of stalling on a downed primary - safe ONLY because both central nodes serve one shared SQL secret store (scadaproj#4), so either hub answers with the same manifest; the appsettings comments say so and warn against listing endpoints backed by independent stores. appsettings.json gains "FallbackEndpoints": [] with a _fallbackEndpoints comment, and the _endpoint note's single-endpoint-stall caveat is scoped to the empty-list case it now only applies to. Wiring pins (red first on 0.4.1): site + Grpc + one fallback resolves ISecretsHubReader to FailoverSecretsHubReader with the "zb-secrets-grpc-hub:fallback:0" keyed channel present; zero fallbacks keeps the plain GrpcSecretsHubClient and no fallback channel - the pre-0.5.0 container shape byte-identical. Claude-Session: https://claude.ai/code/session_014WNM4vjoVksyyBraTXSZE1
This commit is contained in:
@@ -104,11 +104,11 @@
|
|||||||
<PackageVersion Include="ZB.MOM.WW.Auth.AspNetCore" Version="0.1.5" />
|
<PackageVersion Include="ZB.MOM.WW.Auth.AspNetCore" Version="0.1.5" />
|
||||||
<PackageVersion Include="ZB.MOM.WW.Audit" Version="0.1.0" />
|
<PackageVersion Include="ZB.MOM.WW.Audit" Version="0.1.0" />
|
||||||
<PackageVersion Include="ZB.MOM.WW.Theme" Version="0.3.1" />
|
<PackageVersion Include="ZB.MOM.WW.Theme" Version="0.3.1" />
|
||||||
<PackageVersion Include="ZB.MOM.WW.Secrets" Version="0.4.1" />
|
<PackageVersion Include="ZB.MOM.WW.Secrets" Version="0.5.0" />
|
||||||
<PackageVersion Include="ZB.MOM.WW.Secrets.Abstractions" Version="0.4.1" />
|
<PackageVersion Include="ZB.MOM.WW.Secrets.Abstractions" Version="0.5.0" />
|
||||||
<PackageVersion Include="ZB.MOM.WW.Secrets.Ui" Version="0.4.1" />
|
<PackageVersion Include="ZB.MOM.WW.Secrets.Ui" Version="0.5.0" />
|
||||||
<PackageVersion Include="ZB.MOM.WW.Secrets.Replicator.SqlServer" Version="0.4.1" />
|
<PackageVersion Include="ZB.MOM.WW.Secrets.Replicator.SqlServer" Version="0.5.0" />
|
||||||
<PackageVersion Include="ZB.MOM.WW.Secrets.Replicator.Grpc" Version="0.4.1" />
|
<PackageVersion Include="ZB.MOM.WW.Secrets.Replicator.Grpc" Version="0.5.0" />
|
||||||
<PackageVersion Include="ZB.MOM.WW.LocalDb" Version="0.1.3" />
|
<PackageVersion Include="ZB.MOM.WW.LocalDb" Version="0.1.3" />
|
||||||
<PackageVersion Include="ZB.MOM.WW.LocalDb.Replication" Version="0.1.3" />
|
<PackageVersion Include="ZB.MOM.WW.LocalDb.Replication" Version="0.1.3" />
|
||||||
<PackageVersion Include="ZB.MOM.WW.LocalDb.Contracts" Version="0.1.3" />
|
<PackageVersion Include="ZB.MOM.WW.LocalDb.Contracts" Version="0.1.3" />
|
||||||
|
|||||||
@@ -30,8 +30,10 @@
|
|||||||
"_comment": "Read ONLY when Secrets:Replication:Enabled is true AND Mode=Grpc; inert otherwise. ONE section, both halves: CENTRAL reads BearerToken + MaxNamesPerRequest and hosts the hub on its CentralGrpcPort h2c listener (default 8083, alongside CentralControlService); a SITE reads Endpoint + BearerToken + the sweep timings and pulls. Replication is pull-only by wire contract - the proto has no write RPC - so secrets originate at central and a site cannot push. FAIL-CLOSED: unlike SqlServer mode there is no local-only fallback; a missing BearerToken (either role) or Endpoint (site) is a startup failure naming the key.",
|
"_comment": "Read ONLY when Secrets:Replication:Enabled is true AND Mode=Grpc; inert otherwise. ONE section, both halves: CENTRAL reads BearerToken + MaxNamesPerRequest and hosts the hub on its CentralGrpcPort h2c listener (default 8083, alongside CentralControlService); a SITE reads Endpoint + BearerToken + the sweep timings and pulls. Replication is pull-only by wire contract - the proto has no write RPC - so secrets originate at central and a site cannot push. FAIL-CLOSED: unlike SqlServer mode there is no local-only fallback; a missing BearerToken (either role) or Endpoint (site) is a startup failure naming the key.",
|
||||||
"_bearerToken": "Shared credential every follower presents. Supply it from appsettings or the environment (Secrets__GrpcHub__BearerToken), NOT as a ${secret:...} reference - resolving that reference is what the hub exists to make possible, so it cannot come from the hub. Same rationale and same handling as the mesh pre-shared keys. Never commit a real value here; the empty default below is fail-closed, not open.",
|
"_bearerToken": "Shared credential every follower presents. Supply it from appsettings or the environment (Secrets__GrpcHub__BearerToken), NOT as a ${secret:...} reference - resolving that reference is what the hub exists to make possible, so it cannot come from the hub. Same rationale and same handling as the mesh pre-shared keys. Never commit a real value here; the empty default below is fail-closed, not open.",
|
||||||
"BearerToken": "",
|
"BearerToken": "",
|
||||||
"_endpoint": "SITE ONLY. Absolute http/https URI of a CENTRAL node's gRPC (h2c) port - the same address family as ScadaBridge:Communication:CentralGrpcEndpoints, e.g. 'http://central-a-host:8083'. NOT via Traefik (HTTP/1 only). NOTE the asymmetry with CentralGrpcEndpoints: that is a LIST and fails over across the central pair, whereas the hub client dials a SINGLE endpoint. A sweep against a downed central-a therefore stalls rather than failing over - which is survivable because the sweep is best-effort (one warning per interval, then retry) and the site keeps serving its full local last-known-good store, but it does mean secrets stop converging until that central node returns.",
|
"_endpoint": "SITE ONLY. Absolute http/https URI of a CENTRAL node's gRPC (h2c) port - the same address family as ScadaBridge:Communication:CentralGrpcEndpoints, e.g. 'http://central-a-host:8083'. NOT via Traefik (HTTP/1 only). With FallbackEndpoints left empty the hub client dials this SINGLE endpoint only: a sweep against a downed central-a then stalls rather than failing over - survivable because the sweep is best-effort (one warning per interval, then retry) and the site keeps serving its full local last-known-good store, but secrets stop converging until that central node returns. Listing the second central under FallbackEndpoints removes that stall.",
|
||||||
"Endpoint": "",
|
"Endpoint": "",
|
||||||
|
"_fallbackEndpoints": "SITE ONLY. List the SECOND central node's hub endpoint here (same URI form as Endpoint, e.g. 'http://central-b-host:8083') so a sweep fails over across the central pair instead of stalling on a downed primary. Safe ONLY because both central nodes serve ONE shared SQL secret store, so either hub answers with the same manifest - NEVER list an endpoint backed by an independent store: failing over to an emptier hub is a silent convergence stop, the exact defect scadaproj#4 recorded. Empty by default; leaving it empty keeps the pre-0.5.0 single-endpoint behavior byte-identical.",
|
||||||
|
"FallbackEndpoints": [],
|
||||||
"SyncInterval": "00:00:30",
|
"SyncInterval": "00:00:30",
|
||||||
"SyncOnStartup": true,
|
"SyncOnStartup": true,
|
||||||
"CallDeadline": "00:00:30",
|
"CallDeadline": "00:00:30",
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using Grpc.Net.Client;
|
||||||
using Microsoft.AspNetCore.Builder;
|
using Microsoft.AspNetCore.Builder;
|
||||||
using Microsoft.AspNetCore.Routing;
|
using Microsoft.AspNetCore.Routing;
|
||||||
using Microsoft.Extensions.Configuration;
|
using Microsoft.Extensions.Configuration;
|
||||||
@@ -6,6 +7,7 @@ using Microsoft.Extensions.Hosting;
|
|||||||
using ZB.MOM.WW.Secrets.Abstractions;
|
using ZB.MOM.WW.Secrets.Abstractions;
|
||||||
using ZB.MOM.WW.Secrets.Replication;
|
using ZB.MOM.WW.Secrets.Replication;
|
||||||
using ZB.MOM.WW.Secrets.Replicator.Grpc;
|
using ZB.MOM.WW.Secrets.Replicator.Grpc;
|
||||||
|
using ZB.MOM.WW.Secrets.Replicator.Grpc.DependencyInjection;
|
||||||
using ZB.MOM.WW.Secrets.Replicator.SqlServer;
|
using ZB.MOM.WW.Secrets.Replicator.SqlServer;
|
||||||
using ZB.MOM.WW.Secrets.Sqlite;
|
using ZB.MOM.WW.Secrets.Sqlite;
|
||||||
|
|
||||||
@@ -38,6 +40,27 @@ public class SecretsReplicationWiringTests
|
|||||||
// Syntactically valid, deliberately unreachable. Never dialled by these tests.
|
// Syntactically valid, deliberately unreachable. Never dialled by these tests.
|
||||||
private const string DummyHubEndpoint = "http://unused.invalid:8083";
|
private const string DummyHubEndpoint = "http://unused.invalid:8083";
|
||||||
|
|
||||||
|
// Syntactically valid, deliberately unreachable. Never dialled by these tests.
|
||||||
|
private const string DummyFallbackHubEndpoint = "http://unused-fallback.invalid:8083";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Keyed service key of the <see cref="GrpcChannel"/> dialing fallback endpoint 0 — the 0.5.0
|
||||||
|
/// package contract: the primary channel keeps
|
||||||
|
/// <see cref="SecretsGrpcHubClientExtensions.ChannelServiceKey"/> unchanged and fallback
|
||||||
|
/// <c>i</c> gets <c>:fallback:i</c> appended.
|
||||||
|
/// </summary>
|
||||||
|
private const string FallbackChannelKey =
|
||||||
|
SecretsGrpcHubClientExtensions.ChannelServiceKey + ":fallback:0";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The package-internal reader seam the follower sweep pulls through
|
||||||
|
/// (<c>ISecretsHubReader</c>). Internal to the package, so it is obtained by full name and
|
||||||
|
/// resolved by <see cref="Type"/> — visibility never gates DI resolution, only compile-time
|
||||||
|
/// references.
|
||||||
|
/// </summary>
|
||||||
|
private static readonly Type HubReaderInterface = typeof(SecretsHubAuthInterceptor).Assembly
|
||||||
|
.GetType("ZB.MOM.WW.Secrets.Replicator.Grpc.ISecretsHubReader", throwOnError: true)!;
|
||||||
|
|
||||||
// Not a credential — a non-blank placeholder, which is all the fail-closed validators check.
|
// Not a credential — a non-blank placeholder, which is all the fail-closed validators check.
|
||||||
private const string DummyBearerToken = "test-hub-token";
|
private const string DummyBearerToken = "test-hub-token";
|
||||||
|
|
||||||
@@ -326,6 +349,44 @@ public class SecretsReplicationWiringTests
|
|||||||
Assert.Null(provider.GetService<SecretsHubAuthInterceptor>());
|
Assert.Null(provider.GetService<SecretsHubAuthInterceptor>());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A configured fallback endpoint swaps the sweep's reader for the package's failover
|
||||||
|
/// composition and adds one keyed channel per fallback. Safe ONLY because both central nodes
|
||||||
|
/// serve one shared SQL store (scadaproj#4) — the type-name assertion is deliberate: the
|
||||||
|
/// failover reader is internal to the package, and its name is the observable contract here.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void GrpcMode_Site_WithFallbackEndpoint_ResolvesTheFailoverReader()
|
||||||
|
{
|
||||||
|
IConfiguration config = BuildConfig(
|
||||||
|
[
|
||||||
|
.. GrpcSiteConfig(),
|
||||||
|
("Secrets:GrpcHub:FallbackEndpoints:0", DummyFallbackHubEndpoint),
|
||||||
|
]);
|
||||||
|
|
||||||
|
using ServiceProvider provider = BuildProvider(config, SecretsNodeRole.Site);
|
||||||
|
|
||||||
|
object reader = provider.GetRequiredService(HubReaderInterface);
|
||||||
|
Assert.Equal("FailoverSecretsHubReader", reader.GetType().Name);
|
||||||
|
Assert.NotNull(provider.GetKeyedService<GrpcChannel>(FallbackChannelKey));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Zero fallbacks pins the unchanged default: the reader stays the plain single-endpoint
|
||||||
|
/// client and no fallback channel enters the container — exactly the pre-0.5.0 shape every
|
||||||
|
/// existing deployment is in.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void GrpcMode_Site_WithoutFallbackEndpoints_KeepsThePlainHubClient()
|
||||||
|
{
|
||||||
|
using ServiceProvider provider =
|
||||||
|
BuildProvider(BuildConfig(GrpcSiteConfig()), SecretsNodeRole.Site);
|
||||||
|
|
||||||
|
object reader = provider.GetRequiredService(HubReaderInterface);
|
||||||
|
Assert.Equal("GrpcSecretsHubClient", reader.GetType().Name);
|
||||||
|
Assert.Null(provider.GetKeyedService<GrpcChannel>(FallbackChannelKey));
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------------------
|
||||||
// gRPC mode fails closed. There is no local-only fallback here, unlike SQL-Server mode:
|
// gRPC mode fails closed. There is no local-only fallback here, unlike SQL-Server mode:
|
||||||
// a site quietly serving secrets that never converge is the outcome the hub exists to prevent.
|
// a site quietly serving secrets that never converge is the outcome the hub exists to prevent.
|
||||||
|
|||||||
Reference in New Issue
Block a user