feat(secrets): Grpc hub client — pull-only sweep service + options + DI
Client half of the central-hosted secrets hub: a site node follows central and never pushes. - `ISecretsHubReader` + `GrpcSecretsHubClient` — the read half of the hub as a follower sees it (manifest + bulk fetch, nothing else). Bearer credential and a per-call deadline are attached per call; responses convert through `WireToDomain`. Faults are deliberately not caught here, so an unreachable hub can never be mistaken for an empty one. - `WireToDomain` — strict, per-row proto -> domain parse mirroring the Akka replicator's `SecretRowDto`: a name the domain would reject, a content type naming no enum member, or a non-round-trip timestamp is refused at the boundary. Explicit proto3 presence keeps null distinguishable from empty. - `GrpcSecretSyncService` — `SqlServerSecretSyncService` with the push half removed. One `ReconcileAsync` per tick (pull-only by the reconciler's own contract, and batched, so a cold-start pull set cannot exceed gRPC's 4 MB cap), never throws out of `ExecuteAsync`, warns once and retries on the next interval. The local store is left completely undecorated — the sweep is its only hub-ward writer. - `SecretsGrpcHubClientOptions` — fail-closed `Validate()` naming the exact configuration key, including the section a caller actually bound. The bearer token is never echoed by any message. - `AddZbSecretsGrpcHubClient` — binds, eagerly validates, and registers the channel under a service KEY so it cannot lose a `TryAdd` race to an application's own `GrpcChannel` and dial the wrong endpoint with the hub credential attached. Tests 52/52 (red-first), including a source-scan guard pinning that no client file names `ComputePushSet`, `ReadLocalAsync`, or `ApplyReplicatedAsync`, and a reflection pin that the reader seam exposes exactly two read operations. Claude-Session: https://claude.ai/code/session_014WNM4vjoVksyyBraTXSZE1
This commit is contained in:
+112
@@ -0,0 +1,112 @@
|
||||
namespace ZB.MOM.WW.Secrets.Replicator.Grpc;
|
||||
|
||||
/// <summary>
|
||||
/// Configuration for a site node that follows the central-hosted secrets hub. Bound from a
|
||||
/// configuration section by <c>AddZbSecretsGrpcHubClient</c>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Every field here describes how to <em>reach</em> the hub. There is deliberately no knob that
|
||||
/// changes the direction of replication: this package is pull-only by construction and the wire
|
||||
/// contract carries no write RPC, so a follower cannot be configured into pushing.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class SecretsGrpcHubClientOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// The hub's gRPC address, as an absolute <c>http</c> or <c>https</c> URI (for example
|
||||
/// <c>https://central:5001</c>). Required.
|
||||
/// </summary>
|
||||
public string Endpoint { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Shared bearer token presented on every call. Required — an unset token is a hard startup
|
||||
/// failure rather than an anonymous call, because the hub authenticates on this alone.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This value is never logged, never included in an exception message, and never echoed back by
|
||||
/// validation: a startup log is read by far more people than the deployment secret store is.
|
||||
/// Supply it the way the mesh PSKs are supplied — appsettings or environment — <b>not</b> as a
|
||||
/// <c>${secret:}</c> reference, which would need the very store this client exists to fill.
|
||||
/// </remarks>
|
||||
public string BearerToken { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// How often the follower sweeps the hub for rows it is missing or behind on. Defaults to
|
||||
/// 30 seconds. Must be positive.
|
||||
/// </summary>
|
||||
public TimeSpan SyncInterval { get; set; } = TimeSpan.FromSeconds(30);
|
||||
|
||||
/// <summary>
|
||||
/// When <see langword="true"/> (the default), a sweep runs at startup before the first interval
|
||||
/// elapses, so a node that was down while central wrote does not serve stale secrets for a whole
|
||||
/// interval after it comes back.
|
||||
/// </summary>
|
||||
public bool SyncOnStartup { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Deadline applied to each hub call. Defaults to 30 seconds — long enough for a busy central
|
||||
/// node, short enough that an unreachable hub fails the sweep and is retried on the next tick
|
||||
/// instead of pinning the sweep loop indefinitely.
|
||||
/// </summary>
|
||||
public TimeSpan CallDeadline { get; set; } = TimeSpan.FromSeconds(30);
|
||||
|
||||
/// <summary>
|
||||
/// Throws if the options are unusable. Called by the DI extension at registration time so a
|
||||
/// misconfigured node fails at startup rather than on the first silent sweep failure.
|
||||
/// </summary>
|
||||
/// <param name="sectionPath">
|
||||
/// The configuration section the values were bound from, so each message names the key an
|
||||
/// operator actually has to edit rather than the default one.
|
||||
/// </param>
|
||||
/// <exception cref="InvalidOperationException">
|
||||
/// The endpoint is missing or is not an absolute http/https URI, the bearer token is missing, or
|
||||
/// an interval is not positive.
|
||||
/// </exception>
|
||||
public void Validate(string sectionPath = DefaultSectionPath)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(sectionPath);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(Endpoint))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"gRPC secrets hub client requires an Endpoint ({sectionPath}:Endpoint).");
|
||||
}
|
||||
|
||||
if (!Uri.TryCreate(Endpoint, UriKind.Absolute, out Uri? uri) ||
|
||||
(uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps))
|
||||
{
|
||||
// Rejected here rather than at GrpcChannel.ForAddress, which throws deep inside the
|
||||
// first sweep — by which point the failure looks like a hub outage instead of a typo.
|
||||
throw new InvalidOperationException(
|
||||
$"gRPC secrets hub client Endpoint must be an absolute http or https URI " +
|
||||
$"({sectionPath}:Endpoint, was '{Endpoint}').");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(BearerToken))
|
||||
{
|
||||
// Fail closed. An empty token would otherwise be presented as `Bearer `, which the hub
|
||||
// rejects on every call — an authentication outage that looks like a network problem.
|
||||
throw new InvalidOperationException(
|
||||
$"gRPC secrets hub client requires a BearerToken ({sectionPath}:BearerToken); " +
|
||||
"it is unset, so this node has no way to authenticate to the hub.");
|
||||
}
|
||||
|
||||
if (SyncInterval <= TimeSpan.Zero)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"gRPC secrets hub client SyncInterval must be positive " +
|
||||
$"({sectionPath}:SyncInterval, was {SyncInterval}).");
|
||||
}
|
||||
|
||||
if (CallDeadline <= TimeSpan.Zero)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"gRPC secrets hub client CallDeadline must be positive " +
|
||||
$"({sectionPath}:CallDeadline, was {CallDeadline}).");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The configuration section these options are bound from unless a caller overrides it.</summary>
|
||||
public const string DefaultSectionPath = "Secrets:GrpcHub";
|
||||
}
|
||||
Reference in New Issue
Block a user