Files
scadaproj/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Replicator.Grpc/DependencyInjection/SecretsGrpcHubClientExtensions.cs
T
Joseph Doherty 0d4978953f feat(secrets-grpc): FallbackEndpoints + sticky failover hub reader (scadaproj#4)
SecretsGrpcHubClientOptions gains a getter-only FallbackEndpoints list:
additional hub addresses tried in order when a call fails with a
transport-level RpcException. Validation fails startup on blank/relative
entries and on any duplicate of the primary or another fallback (a
duplicated endpoint silently halves the configured failover).

New FailoverSecretsHubReader composes one GrpcSecretsHubClient per
endpoint: the answering endpoint is sticky (a sweep is GetManifest + N
GetSecrets batches — without stickiness every call after a primary
outage re-pays a full CallDeadline before reaching the healthy
fallback), rotation wraps so a recovered primary is retried when the
stuck-to endpoint fails, only RpcException triggers failover
(cancellation and defects propagate untouched), and when every endpoint
fails the last RpcException is rethrown so the sweep degrades to
last-known-good exactly as today. Failover warnings name only the two
endpoint URIs and the status code — never any credential material.

DI: zero fallbacks keeps the registration graph byte-identical to 0.4.x
(single keyed channel under ChannelServiceKey, plain
GrpcSecretsHubClient); with fallbacks, one container-owned keyed channel
per endpoint (fallback i under "{ChannelServiceKey}:fallback:{i}") wired
into the failover reader. GrpcSecretsHubClient itself is unchanged.

Safe only when every listed endpoint serves the SAME store — for
ScadaBridge, the central pair over one shared SQL Server store; the
options doc and CHANGELOG carry the warning.

Claude-Session: https://claude.ai/code/session_014WNM4vjoVksyyBraTXSZE1
2026-08-07 09:37:22 -04:00

168 lines
8.8 KiB
C#

using Grpc.Net.Client;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.Secrets.Abstractions;
using ZB.MOM.WW.Secrets.Replicator.Grpc.V1;
namespace ZB.MOM.WW.Secrets.Replicator.Grpc.DependencyInjection;
/// <summary>
/// Dependency-injection entry point for a site node that follows the central-hosted secrets hub.
/// </summary>
/// <remarks>
/// <para>
/// <b>The hub and every follower need the same KEK.</b> Only ciphertext crosses the wire, so a node
/// whose master key differs cannot decrypt rows central wrote — it fails closed on resolve with a
/// <c>kek_id</c> mismatch, which reads like data corruption but is a deployment error.
/// </para>
/// <para>
/// <b>This is the follower half only.</b> Central hosts the hub service and must not register this
/// client; a site node registers this client and must not host the service. Nothing here can push a
/// row hub-ward — the contract has no write RPC — so the direction of replication is a property of
/// the deployment topology rather than a setting that could be got wrong.
/// </para>
/// </remarks>
public static class SecretsGrpcHubClientExtensions
{
/// <summary>
/// Service key under which this package's <see cref="GrpcChannel"/> is registered, so it can
/// never be confused with a channel the application registered for something else.
/// </summary>
public const string ChannelServiceKey = "zb-secrets-grpc-hub";
/// <summary>
/// Registers the pull-only hub client and its background sweep: a channel to the configured
/// endpoint, the generated gRPC client, the reader seam over it, and a hosted service that keeps
/// the node's local store converged with central.
/// </summary>
/// <remarks>
/// The local <see cref="ISecretStore"/> is <b>not</b> registered or decorated here — call
/// <c>AddZbSecrets</c> as usual and this sits alongside it. That is the whole difference from the
/// SQL-Server hub-replication mode, which wraps the store in a publishing decorator: pull-only
/// means local writes never leave the node, so there is nothing to publish and the store stays
/// exactly as the application configured it. A host that forgets <c>AddZbSecrets</c> fails loudly
/// at startup when the sweep cannot resolve the store, rather than silently replicating nothing.
/// <para>
/// When <see cref="SecretsGrpcHubClientOptions.FallbackEndpoints"/> is non-empty, one keyed
/// channel and one client are registered per endpoint and the reader becomes a
/// <see cref="FailoverSecretsHubReader"/> composed over them, primary first. With the list
/// empty — the default — the registration graph is identical to releases before the list
/// existed: one keyed channel, one <see cref="GrpcSecretsHubClient"/>, no failover wrapper.
/// </para>
/// </remarks>
/// <param name="services">The service collection to add to.</param>
/// <param name="configuration">The application configuration.</param>
/// <param name="sectionPath">Configuration section holding the hub-client options.</param>
/// <returns>The same <paramref name="services"/> instance, for chaining.</returns>
/// <exception cref="InvalidOperationException">
/// The bound options are unusable — see <see cref="SecretsGrpcHubClientOptions.Validate"/>.
/// </exception>
public static IServiceCollection AddZbSecretsGrpcHubClient(
this IServiceCollection services,
IConfiguration configuration,
string sectionPath = SecretsGrpcHubClientOptions.DefaultSectionPath)
{
ArgumentNullException.ThrowIfNull(services);
ArgumentNullException.ThrowIfNull(configuration);
ArgumentException.ThrowIfNullOrWhiteSpace(sectionPath);
IConfigurationSection section = configuration.GetSection(sectionPath);
services.Configure<SecretsGrpcHubClientOptions>(section);
// Bind and validate NOW as well as through the options pipeline: a missing endpoint or an
// unset bearer token should fail at startup with a message naming the key, not surface as a
// sweep that warns once every interval and never converges. The eagerly-bound instance is
// discarded immediately — it is a credential holder, and everything downstream reads the
// options from IOptions rather than closing over it.
var eager = new SecretsGrpcHubClientOptions();
section.Bind(eager);
eager.Validate(sectionPath);
// Singleton, so the container owns its disposal — a channel per sweep would leak an HTTP/2
// connection every interval. KEYED, because GrpcChannel is a framework type an application
// very plausibly registers for one of its own gRPC dependencies: an unkeyed TryAddSingleton
// would silently lose to that registration and this client would then dial someone else's
// endpoint with the hub credential attached.
services.TryAddKeyedSingleton(ChannelServiceKey, (sp, _) => GrpcChannel.ForAddress(
sp.GetRequiredService<IOptions<SecretsGrpcHubClientOptions>>().Value.Endpoint));
if (eager.FallbackEndpoints.Count == 0)
{
// No fallbacks: the graph is exactly the pre-0.5.0 one — the single keyed channel above
// plus one GrpcSecretsHubClient as the reader. Kept as its own branch rather than a
// one-element failover composition so existing consumers see an identical container
// shape and zero added indirection on every call.
services.TryAddSingleton<ISecretsHubReader>(sp => new GrpcSecretsHubClient(
new SecretsHub.SecretsHubClient(
sp.GetRequiredKeyedService<GrpcChannel>(ChannelServiceKey)),
sp.GetRequiredService<IOptions<SecretsGrpcHubClientOptions>>(),
sp.GetRequiredService<ILogger<GrpcSecretsHubClient>>(),
sp.GetService<TimeProvider>()));
}
else
{
// One keyed channel per fallback, each a container-owned singleton for the same
// disposal reason as the primary. The loop variable is copied because the factory
// closes over it and runs long after the loop has finished.
for (int i = 0; i < eager.FallbackEndpoints.Count; i++)
{
int index = i;
services.TryAddKeyedSingleton(
FallbackChannelServiceKey(index),
(sp, _) => GrpcChannel.ForAddress(sp
.GetRequiredService<IOptions<SecretsGrpcHubClientOptions>>()
.Value.FallbackEndpoints[index]));
}
services.TryAddSingleton<ISecretsHubReader>(sp =>
{
IOptions<SecretsGrpcHubClientOptions> options =
sp.GetRequiredService<IOptions<SecretsGrpcHubClientOptions>>();
ILogger<GrpcSecretsHubClient> clientLogger =
sp.GetRequiredService<ILogger<GrpcSecretsHubClient>>();
TimeProvider? timeProvider = sp.GetService<TimeProvider>();
GrpcSecretsHubClient ReaderOver(object channelKey) => new(
new SecretsHub.SecretsHubClient(
sp.GetRequiredKeyedService<GrpcChannel>(channelKey)),
options,
clientLogger,
timeProvider);
IList<string> fallbacks = options.Value.FallbackEndpoints;
var readers = new List<ISecretsHubReader>(1 + fallbacks.Count)
{
ReaderOver(ChannelServiceKey),
};
var labels = new List<string>(1 + fallbacks.Count) { options.Value.Endpoint };
for (int index = 0; index < fallbacks.Count; index++)
{
readers.Add(ReaderOver(FallbackChannelServiceKey(index)));
labels.Add(fallbacks[index]);
}
return new FailoverSecretsHubReader(
readers, labels, sp.GetRequiredService<ILogger<FailoverSecretsHubReader>>());
});
}
services.AddHostedService<GrpcSecretSyncService>();
return services;
}
/// <summary>
/// Service key for the keyed <see cref="GrpcChannel"/> dialing the fallback endpoint at
/// <paramref name="index"/> in <see cref="SecretsGrpcHubClientOptions.FallbackEndpoints"/>.
/// The primary endpoint's channel keeps <see cref="ChannelServiceKey"/> unchanged.
/// </summary>
/// <param name="index">Zero-based index into the fallback list.</param>
/// <returns>The service key.</returns>
internal static string FallbackChannelServiceKey(int index) =>
$"{ChannelServiceKey}:fallback:{index}";
}