Files
scadaproj/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Replicator.Grpc/SecretsGrpcHubClientOptions.cs
T
Joseph Doherty b392b84f21 feat(secrets): 0.6.3 — hub sweep outcome logging (in-sync debug + hourly heartbeat + recovered) and ISecretsHubSyncStatus
GrpcSecretSyncService's background sweep now records outcomes on a new
SecretsHubSyncStatus (registered singleton, exposed as public
ISecretsHubSyncStatus) and logs three new events on the outcome-recording
path: EventId 4 (Debug, every in-sync sweep), EventId 5 (Information, the
same outcome throttled to at most once per the new
SecretsGrpcHubClientOptions.InSyncLogInterval, default 1h), and EventId 6
(Information, the first sweep to succeed after one or more failures).
EventIds 1-3 are unchanged; the raw internal SweepAsync still throws on
failure exactly as before. Not yet published to the Gitea feed and
ScadaBridge is not re-pinned — that is a separate, later step.
2026-09-28 17:26:25 -04:00

190 lines
9.6 KiB
C#

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>
/// <remarks>
/// A plain <c>http</c> endpoint is accepted so an h2c-only or in-cluster deployment can be
/// stood up, but it sends <see cref="BearerToken"/> in cleartext on <em>every</em> call — anyone
/// on the path reads the hub credential and can then pull central's entire secret inventory.
/// Treat <c>http</c> as a development and loopback-only convenience; a site node reaching
/// central across a plant network must use <c>https</c>. This is documentation rather than a
/// rule the validator enforces, because "internal" is a property of a deployment's network and
/// cannot be decided from a URI string.
/// </remarks>
public string Endpoint { get; set; } = string.Empty;
/// <summary>
/// Additional hub addresses tried, in order, when a call to <see cref="Endpoint"/> (or an
/// earlier fallback) fails with a transport-level gRPC error. Optional; empty by default.
/// Meaningful only when every listed endpoint serves the SAME store — for ScadaBridge that
/// is the central pair over one shared SQL Server store. Do NOT list endpoints backed by
/// independent stores: failing over to an emptier hub is a silent convergence stop, which is
/// the exact defect (scadaproj#4) the shared store exists to prevent.
/// </summary>
/// <remarks>
/// Getter-only so the configuration binder adds entries to this instance rather than needing a
/// setter. Mutating the list after <see cref="Validate"/> has run is unsupported — the DI
/// extension reads it exactly once, at registration, to shape the channel graph. Each entry
/// carries the same http-cleartext caveat as <see cref="Endpoint"/>: a plain <c>http</c>
/// fallback sends <see cref="BearerToken"/> in cleartext on every failed-over call.
/// </remarks>
public IList<string> FallbackEndpoints { get; } = [];
/// <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>
/// How often an in-sync sweep (nothing to pull) is allowed to log an Information-level
/// heartbeat. Defaults to one hour. Must be positive.
/// </summary>
/// <remarks>
/// A converged follower sweeps every <see cref="SyncInterval"/> — 30 seconds by default — and
/// finds nothing new almost every time. Logging that outcome at Information on every sweep
/// would drown the log; staying silent forever would make "the sweep loop is alive and the hub
/// still agrees with this node" unobservable between the rare sweeps that actually pull
/// something. Every sweep still logs at Debug regardless of this interval, so a verbose log
/// level shows the true cadence when it is needed.
/// </remarks>
public TimeSpan InSyncLogInterval { get; set; } = TimeSpan.FromHours(1);
/// <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, a fallback endpoint is blank,
/// not an absolute http/https URI, or duplicates another endpoint, 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}').");
}
var seenEndpoints = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { Endpoint };
foreach (string fallback in FallbackEndpoints)
{
if (string.IsNullOrWhiteSpace(fallback))
{
throw new InvalidOperationException(
$"gRPC secrets hub client fallback endpoints must not be blank " +
$"({sectionPath}:FallbackEndpoints).");
}
if (!Uri.TryCreate(fallback, UriKind.Absolute, out Uri? fallbackUri) ||
(fallbackUri.Scheme != Uri.UriSchemeHttp && fallbackUri.Scheme != Uri.UriSchemeHttps))
{
throw new InvalidOperationException(
$"gRPC secrets hub client fallback endpoints must be absolute http or https " +
$"URIs ({sectionPath}:FallbackEndpoints, entry '{fallback}').");
}
if (!seenEndpoints.Add(fallback))
{
// A duplicated endpoint silently halves the failover the operator thinks they
// configured: the rotation would try the same address twice and call it two tries.
throw new InvalidOperationException(
$"gRPC secrets hub client fallback endpoints must not repeat the Endpoint or " +
$"each other ({sectionPath}:FallbackEndpoints, entry '{fallback}').");
}
}
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}).");
}
if (InSyncLogInterval <= TimeSpan.Zero)
{
throw new InvalidOperationException(
$"gRPC secrets hub client InSyncLogInterval must be positive " +
$"({sectionPath}:InSyncLogInterval, was {InSyncLogInterval}).");
}
}
/// <summary>The configuration section these options are bound from unless a caller overrides it.</summary>
public const string DefaultSectionPath = "Secrets:GrpcHub";
}