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:
Joseph Doherty
2026-08-07 06:12:03 -04:00
parent cc95590196
commit c5ea3a7b59
8 changed files with 1429 additions and 0 deletions
@@ -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";
}