b392b84f21
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.
190 lines
9.6 KiB
C#
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";
|
|
}
|