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:
Joseph Doherty
2026-08-07 10:55:38 -04:00
parent aebdd56b52
commit 68f812eaa4
3 changed files with 69 additions and 6 deletions
@@ -1,3 +1,4 @@
using Grpc.Net.Client;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.Configuration;
@@ -6,6 +7,7 @@ using Microsoft.Extensions.Hosting;
using ZB.MOM.WW.Secrets.Abstractions;
using ZB.MOM.WW.Secrets.Replication;
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.Sqlite;
@@ -38,6 +40,27 @@ public class SecretsReplicationWiringTests
// Syntactically valid, deliberately unreachable. Never dialled by these tests.
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.
private const string DummyBearerToken = "test-hub-token";
@@ -326,6 +349,44 @@ public class SecretsReplicationWiringTests
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:
// a site quietly serving secrets that never converge is the outcome the hub exists to prevent.