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;
///
/// Dependency-injection entry point for a site node that follows the central-hosted secrets hub.
///
///
///
/// The hub and every follower need the same KEK. Only ciphertext crosses the wire, so a node
/// whose master key differs cannot decrypt rows central wrote — it fails closed on resolve with a
/// kek_id mismatch, which reads like data corruption but is a deployment error.
///
///
/// This is the follower half only. 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.
///
///
public static class SecretsGrpcHubClientExtensions
{
///
/// Service key under which this package's is registered, so it can
/// never be confused with a channel the application registered for something else.
///
public const string ChannelServiceKey = "zb-secrets-grpc-hub";
///
/// 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.
///
///
/// The local is not registered or decorated here — call
/// AddZbSecrets 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 AddZbSecrets fails loudly
/// at startup when the sweep cannot resolve the store, rather than silently replicating nothing.
///
/// When is non-empty, one keyed
/// channel and one client are registered per endpoint and the reader becomes a
/// 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 , no failover wrapper.
///
///
/// The service collection to add to.
/// The application configuration.
/// Configuration section holding the hub-client options.
/// The same instance, for chaining.
///
/// The bound options are unusable — see .
///
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(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>().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(sp => new GrpcSecretsHubClient(
new SecretsHub.SecretsHubClient(
sp.GetRequiredKeyedService(ChannelServiceKey)),
sp.GetRequiredService>(),
sp.GetRequiredService>(),
sp.GetService()));
}
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>()
.Value.FallbackEndpoints[index]));
}
services.TryAddSingleton(sp =>
{
IOptions options =
sp.GetRequiredService>();
ILogger clientLogger =
sp.GetRequiredService>();
TimeProvider? timeProvider = sp.GetService();
GrpcSecretsHubClient ReaderOver(object channelKey) => new(
new SecretsHub.SecretsHubClient(
sp.GetRequiredKeyedService(channelKey)),
options,
clientLogger,
timeProvider);
IList fallbacks = options.Value.FallbackEndpoints;
var readers = new List(1 + fallbacks.Count)
{
ReaderOver(ChannelServiceKey),
};
var labels = new List(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>());
});
}
services.AddHostedService();
return services;
}
///
/// Service key for the keyed dialing the fallback endpoint at
/// in .
/// The primary endpoint's channel keeps unchanged.
///
/// Zero-based index into the fallback list.
/// The service key.
internal static string FallbackChannelServiceKey(int index) =>
$"{ChannelServiceKey}:fallback:{index}";
}