0d4978953f
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
168 lines
8.8 KiB
C#
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}";
|
|
}
|