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}"; }