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.
This commit is contained in:
File diff suppressed because one or more lines are too long
@@ -5,7 +5,7 @@
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<Version>0.6.2</Version>
|
||||
<Version>0.6.3</Version>
|
||||
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
|
||||
<PackageReadmeFile>README.md</PackageReadmeFile>
|
||||
</PropertyGroup>
|
||||
|
||||
+9
@@ -150,6 +150,15 @@ public static class SecretsGrpcHubClientExtensions
|
||||
});
|
||||
}
|
||||
|
||||
// Singleton, and registered for BOTH the concrete type and the public interface: the
|
||||
// service needs the concrete type (it calls the internal RecordSuccess/RecordFailure), and
|
||||
// an application observing hub-sync health (a readiness check, a status page) needs only
|
||||
// the read-only ISecretsHubSyncStatus. TryAddSingleton for the concrete type means the
|
||||
// interface registration below resolves the SAME instance rather than constructing a second
|
||||
// one, which would silently split "what the service records" from "what a consumer reads".
|
||||
services.TryAddSingleton<SecretsHubSyncStatus>();
|
||||
services.TryAddSingleton<ISecretsHubSyncStatus>(sp => sp.GetRequiredService<SecretsHubSyncStatus>());
|
||||
|
||||
services.AddHostedService<GrpcSecretSyncService>();
|
||||
|
||||
return services;
|
||||
|
||||
@@ -36,6 +36,13 @@ internal sealed partial class GrpcSecretSyncService : BackgroundService
|
||||
private readonly SecretsGrpcHubClientOptions _options;
|
||||
private readonly ILogger<GrpcSecretSyncService> _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly SecretsHubSyncStatus _status;
|
||||
|
||||
// Last time a row actually moved, and last time the in-sync heartbeat was logged — both
|
||||
// instance state because they describe THIS service's observed history, not anything the hub
|
||||
// or the local store can answer on demand.
|
||||
private DateTimeOffset? _lastPullUtc;
|
||||
private DateTimeOffset? _lastInSyncHeartbeatUtc;
|
||||
|
||||
/// <summary>Creates the sync service.</summary>
|
||||
/// <param name="local">The node's local store — the only thing this service writes to.</param>
|
||||
@@ -44,13 +51,20 @@ internal sealed partial class GrpcSecretSyncService : BackgroundService
|
||||
/// <param name="options">Hub-client options.</param>
|
||||
/// <param name="logger">Logger for sweep outcomes and failures.</param>
|
||||
/// <param name="timeProvider">Clock, injectable so tests need not wait a real interval.</param>
|
||||
/// <param name="status">
|
||||
/// The sweep-outcome tracker this service updates. Optional so existing callers that construct
|
||||
/// this type directly (tests, mainly) keep compiling; a real host resolves the same singleton
|
||||
/// <c>AddZbSecretsGrpcHubClient</c> registers for <see cref="ISecretsHubSyncStatus"/>, so the two
|
||||
/// stay the same instance.
|
||||
/// </param>
|
||||
public GrpcSecretSyncService(
|
||||
ISecretStore local,
|
||||
ISecretsHubReader hub,
|
||||
ISecretCacheInvalidator? cacheInvalidator,
|
||||
IOptions<SecretsGrpcHubClientOptions> options,
|
||||
ILogger<GrpcSecretSyncService> logger,
|
||||
TimeProvider? timeProvider = null)
|
||||
TimeProvider? timeProvider = null,
|
||||
SecretsHubSyncStatus? status = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
ArgumentNullException.ThrowIfNull(local);
|
||||
@@ -59,6 +73,7 @@ internal sealed partial class GrpcSecretSyncService : BackgroundService
|
||||
_options = options.Value;
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
_status = status ?? new SecretsHubSyncStatus();
|
||||
_reconciler = new SecretReplicationReconciler(
|
||||
local,
|
||||
cacheInvalidator,
|
||||
@@ -82,11 +97,22 @@ internal sealed partial class GrpcSecretSyncService : BackgroundService
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Runs one pull-only reconcile against the hub.
|
||||
/// Runs one pull-only reconcile against the hub. Throws on any failure — an unreachable hub, a
|
||||
/// rejected credential, a deadline — so a direct caller sees the raw outcome. The background
|
||||
/// loop never calls this directly; it goes through <see cref="SweepAndRecordAsync"/>, which
|
||||
/// shares this method's reconcile core but also records the outcome and logs it.
|
||||
/// </summary>
|
||||
/// <param name="ct">A token to cancel the sweep.</param>
|
||||
/// <returns>How many rows were pulled from the hub.</returns>
|
||||
internal async Task<int> SweepAsync(CancellationToken ct)
|
||||
internal async Task<int> SweepAsync(CancellationToken ct) =>
|
||||
(await SweepCoreAsync(ct).ConfigureAwait(false)).Pulled;
|
||||
|
||||
/// <summary>
|
||||
/// The reconcile core shared by <see cref="SweepAsync"/> and <see cref="SweepAndRecordAsync"/>:
|
||||
/// fetches the hub manifest and reconciles the local store against it. Throws on failure —
|
||||
/// callers that want failures recorded/logged instead do that around this call, not inside it.
|
||||
/// </summary>
|
||||
private async Task<(int Pulled, int ManifestCount)> SweepCoreAsync(CancellationToken ct)
|
||||
{
|
||||
IReadOnlyList<SecretManifestEntry> hubManifest =
|
||||
await _hub.GetManifestAsync(ct).ConfigureAwait(false);
|
||||
@@ -97,20 +123,54 @@ internal sealed partial class GrpcSecretSyncService : BackgroundService
|
||||
// it rather than re-deriving the diff here also keeps the batching: a cold-starting node's
|
||||
// pull set is central's ENTIRE inventory, and an unchunked GetSecrets would exceed gRPC's
|
||||
// 4 MB default message cap and then fail identically on every interval forever.
|
||||
return await _reconciler
|
||||
int pulled = await _reconciler
|
||||
.ReconcileAsync(hubManifest, (names, token) => _hub.GetManyAsync(names, token), ct)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return (pulled, hubManifest.Count);
|
||||
}
|
||||
|
||||
private async Task SweepSafelyAsync(CancellationToken ct)
|
||||
private Task SweepSafelyAsync(CancellationToken ct) => SweepAndRecordAsync(ct);
|
||||
|
||||
/// <summary>
|
||||
/// Test-only entry point for the same outcome-recording sweep the background loop runs — never
|
||||
/// throws, always updates <see cref="ISecretsHubSyncStatus"/>, and logs exactly as a real tick
|
||||
/// would. Exists so a test can drive one sweep at a time without standing up the
|
||||
/// <see cref="BackgroundService"/> host lifecycle.
|
||||
/// </summary>
|
||||
/// <param name="ct">A token to cancel the sweep.</param>
|
||||
internal Task SweepForTestAsync(CancellationToken ct) => SweepAndRecordAsync(ct);
|
||||
|
||||
private async Task SweepAndRecordAsync(CancellationToken ct)
|
||||
{
|
||||
DateTimeOffset now = _timeProvider.GetUtcNow();
|
||||
|
||||
try
|
||||
{
|
||||
int pulled = await SweepAsync(ct).ConfigureAwait(false);
|
||||
(int pulled, int manifestCount) = await SweepCoreAsync(ct).ConfigureAwait(false);
|
||||
|
||||
int priorFailures = _status.RecordSuccess(now);
|
||||
|
||||
if (priorFailures > 0)
|
||||
{
|
||||
LogSweepRecovered(priorFailures);
|
||||
}
|
||||
|
||||
if (pulled > 0)
|
||||
{
|
||||
LogSweepConverged(pulled);
|
||||
_lastPullUtc = now;
|
||||
}
|
||||
else
|
||||
{
|
||||
LogSweepInSync(manifestCount);
|
||||
|
||||
if (_lastInSyncHeartbeatUtc is null ||
|
||||
now - _lastInSyncHeartbeatUtc.Value >= _options.InSyncLogInterval)
|
||||
{
|
||||
LogSweepInSyncHeartbeat(manifestCount, _lastPullUtc);
|
||||
_lastInSyncHeartbeatUtc = now;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) when (ct.IsCancellationRequested)
|
||||
@@ -124,6 +184,7 @@ internal sealed partial class GrpcSecretSyncService : BackgroundService
|
||||
// has the same correct response: keep serving the local store and try again. Letting any
|
||||
// of it escape would fault the BackgroundService and take the host down over a transient
|
||||
// central outage, which is precisely the availability the pull-only topology exists for.
|
||||
_status.RecordFailure(now, ex);
|
||||
LogSweepFailed(ex);
|
||||
}
|
||||
}
|
||||
@@ -145,4 +206,36 @@ internal sealed partial class GrpcSecretSyncService : BackgroundService
|
||||
Level = LogLevel.Warning,
|
||||
Message = "Discarding replicated secret row {SecretName} that could not be applied locally; the rest of the batch continues.")]
|
||||
private partial void LogRowDiscarded(Exception exception, string secretName);
|
||||
|
||||
/// <summary>
|
||||
/// Logged at Debug on every sweep that found nothing to pull — the full-cadence signal, for a
|
||||
/// verbose log level, that the loop is alive and agrees with the hub even between the rare
|
||||
/// Information-level heartbeats.
|
||||
/// </summary>
|
||||
[LoggerMessage(
|
||||
EventId = 4,
|
||||
Level = LogLevel.Debug,
|
||||
Message = "Secrets hub sweep found nothing new; {Count} secret(s) already in sync.")]
|
||||
private partial void LogSweepInSync(int count);
|
||||
|
||||
/// <summary>
|
||||
/// Logged at Information at most once per <see cref="SecretsGrpcHubClientOptions.InSyncLogInterval"/>
|
||||
/// — an in-sync sweep is otherwise Debug-only, so this is the periodic proof at the default log
|
||||
/// level that the follower is still converged rather than silently stuck.
|
||||
/// </summary>
|
||||
[LoggerMessage(
|
||||
EventId = 5,
|
||||
Level = LogLevel.Information,
|
||||
Message = "Secrets hub in sync ({Count} secret(s)); last change pulled {LastPullUtc}")]
|
||||
private partial void LogSweepInSyncHeartbeat(int count, DateTimeOffset? lastPullUtc);
|
||||
|
||||
/// <summary>
|
||||
/// Logged at Information the first time a sweep succeeds after one or more failures — the
|
||||
/// recovery line that closes out whatever run of <see cref="LogSweepFailed"/> Warnings preceded it.
|
||||
/// </summary>
|
||||
[LoggerMessage(
|
||||
EventId = 6,
|
||||
Level = LogLevel.Information,
|
||||
Message = "Secrets hub sync recovered after {Failures} failed sweep(s)")]
|
||||
private partial void LogSweepRecovered(int failures);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
namespace ZB.MOM.WW.Secrets.Replicator.Grpc;
|
||||
|
||||
/// <summary>Point-in-time outcome of the hub follower's sweeps. Never carries secret material.</summary>
|
||||
/// <remarks>
|
||||
/// Registered as a singleton by <c>AddZbSecretsGrpcHubClient</c> alongside the background sweep
|
||||
/// service, so a host can surface hub-sync health (a readiness check, a status page) without
|
||||
/// scraping logs. Every field reflects the outcome of the most recently completed sweep only —
|
||||
/// there is deliberately no history here, just "what happened last time".
|
||||
/// </remarks>
|
||||
public interface ISecretsHubSyncStatus
|
||||
{
|
||||
/// <summary>
|
||||
/// When the most recent sweep finished, whether it succeeded or failed. <see langword="null"/>
|
||||
/// before the first sweep has completed.
|
||||
/// </summary>
|
||||
DateTimeOffset? LastSweepUtc { get; }
|
||||
|
||||
/// <summary>
|
||||
/// When the most recent <em>successful</em> sweep finished. <see langword="null"/> if no sweep
|
||||
/// has ever succeeded.
|
||||
/// </summary>
|
||||
DateTimeOffset? LastSuccessUtc { get; }
|
||||
|
||||
/// <summary>
|
||||
/// The number of sweeps that have failed in a row, ending at the most recent sweep. Reset to
|
||||
/// zero the moment a sweep succeeds.
|
||||
/// </summary>
|
||||
int ConsecutiveFailures { get; }
|
||||
|
||||
/// <summary>
|
||||
/// The message from the most recent sweep failure, or <see langword="null"/> if the most recent
|
||||
/// sweep succeeded (or none has run yet). This is the exception's type and message only — never
|
||||
/// a stack trace, and never secret material, since the sweep never has plaintext in scope.
|
||||
/// </summary>
|
||||
string? LastError { get; }
|
||||
}
|
||||
|
||||
/// <summary>The mutable implementation the sync service writes to; consumers see only the interface.</summary>
|
||||
internal sealed class SecretsHubSyncStatus : ISecretsHubSyncStatus
|
||||
{
|
||||
private readonly object _gate = new();
|
||||
|
||||
/// <inheritdoc/>
|
||||
public DateTimeOffset? LastSweepUtc { get; private set; }
|
||||
|
||||
/// <inheritdoc/>
|
||||
public DateTimeOffset? LastSuccessUtc { get; private set; }
|
||||
|
||||
/// <inheritdoc/>
|
||||
public int ConsecutiveFailures { get; private set; }
|
||||
|
||||
/// <inheritdoc/>
|
||||
public string? LastError { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Records a successful sweep, resetting the failure streak.
|
||||
/// </summary>
|
||||
/// <param name="now">The instant the sweep completed.</param>
|
||||
/// <returns>
|
||||
/// The consecutive-failure count <em>before</em> this success — the caller uses a non-zero
|
||||
/// result to decide whether the sweep is a recovery worth logging.
|
||||
/// </returns>
|
||||
internal int RecordSuccess(DateTimeOffset now)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
int prior = ConsecutiveFailures;
|
||||
LastSweepUtc = LastSuccessUtc = now;
|
||||
ConsecutiveFailures = 0;
|
||||
LastError = null;
|
||||
return prior;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Records a failed sweep, extending the failure streak.</summary>
|
||||
/// <param name="now">The instant the sweep failed.</param>
|
||||
/// <param name="ex">The exception the sweep failed with.</param>
|
||||
internal void RecordFailure(DateTimeOffset now, Exception ex)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
LastSweepUtc = now;
|
||||
ConsecutiveFailures++;
|
||||
LastError = ex.GetType().Name + ": " + ex.Message;
|
||||
}
|
||||
}
|
||||
}
|
||||
+21
@@ -77,6 +77,20 @@ public sealed class SecretsGrpcHubClientOptions
|
||||
/// </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.
|
||||
@@ -161,6 +175,13 @@ public sealed class SecretsGrpcHubClientOptions
|
||||
$"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>
|
||||
|
||||
+250
@@ -0,0 +1,250 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using ZB.MOM.WW.Secrets.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.Secrets.Replicator.Grpc.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Covers the hub follower's sweep-outcome observability: the Debug-per-sweep / Information-
|
||||
/// heartbeat-once-per-interval pair for an in-sync sweep (EventId 4/5), the recovered line the
|
||||
/// first success after a run of failures logs (EventId 6), and <see cref="ISecretsHubSyncStatus"/>
|
||||
/// tracking the same outcomes.
|
||||
/// </summary>
|
||||
public sealed class GrpcSecretSyncServiceLoggingTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task InSyncSweep_LogsDebugEveryTime_AndInformationHeartbeatOncePerInterval()
|
||||
{
|
||||
var time = new FakeTimeProvider(DateTimeOffset.Parse("2026-09-28T00:00:00Z"));
|
||||
(GrpcSecretSyncService svc, RecordingListLogger logs, _) = Harness.Create(
|
||||
time, inSyncLogInterval: TimeSpan.FromHours(1), manifestUnchanged: true);
|
||||
|
||||
await svc.SweepForTestAsync(CancellationToken.None);
|
||||
time.Advance(TimeSpan.FromMinutes(30));
|
||||
await svc.SweepForTestAsync(CancellationToken.None);
|
||||
time.Advance(TimeSpan.FromMinutes(31));
|
||||
await svc.SweepForTestAsync(CancellationToken.None);
|
||||
|
||||
Assert.Equal(3, logs.Entries.Count(l => l.EventId.Id == 4 && l.Level == LogLevel.Debug));
|
||||
Assert.Equal(2, logs.Entries.Count(l => l.EventId.Id == 5 && l.Level == LogLevel.Information));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FirstSuccessAfterFailures_LogsRecovered_AndStatusResets()
|
||||
{
|
||||
var time = new FakeTimeProvider();
|
||||
(GrpcSecretSyncService svc, RecordingListLogger logs, ISecretsHubSyncStatus status) =
|
||||
Harness.Create(time, failFirst: 2);
|
||||
|
||||
await svc.SweepForTestAsync(default);
|
||||
await svc.SweepForTestAsync(default);
|
||||
|
||||
Assert.Equal(2, status.ConsecutiveFailures);
|
||||
|
||||
await svc.SweepForTestAsync(default);
|
||||
|
||||
Assert.Contains(logs.Entries, l => l.EventId.Id == 6 && l.Message.Contains("after 2 failed"));
|
||||
Assert.Equal(0, status.ConsecutiveFailures);
|
||||
Assert.NotNull(status.LastSuccessUtc);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_failed_sweep_never_logs_EventId6_and_status_records_the_error()
|
||||
{
|
||||
var time = new FakeTimeProvider();
|
||||
(GrpcSecretSyncService svc, RecordingListLogger logs, ISecretsHubSyncStatus status) =
|
||||
Harness.Create(time, failFirst: 1);
|
||||
|
||||
await svc.SweepForTestAsync(default);
|
||||
|
||||
Assert.DoesNotContain(logs.Entries, l => l.EventId.Id == 6);
|
||||
Assert.Equal(1, status.ConsecutiveFailures);
|
||||
Assert.NotNull(status.LastError);
|
||||
Assert.Null(status.LastSuccessUtc);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task An_in_sync_sweep_never_logs_the_recovered_line()
|
||||
{
|
||||
var time = new FakeTimeProvider();
|
||||
(GrpcSecretSyncService svc, RecordingListLogger logs, ISecretsHubSyncStatus status) =
|
||||
Harness.Create(time, manifestUnchanged: true);
|
||||
|
||||
await svc.SweepForTestAsync(default);
|
||||
await svc.SweepForTestAsync(default);
|
||||
|
||||
Assert.DoesNotContain(logs.Entries, l => l.EventId.Id == 6);
|
||||
Assert.Equal(0, status.ConsecutiveFailures);
|
||||
Assert.NotNull(status.LastSweepUtc);
|
||||
}
|
||||
|
||||
// Assembles a GrpcSecretSyncService wired to a controllable fake hub, a no-op local store (the
|
||||
// scenarios here never actually apply a row — an empty hub manifest means the reconciler's pull
|
||||
// set is always empty), a log-capturing logger, and the internal status tracker the service
|
||||
// writes to — so a test can both drive sweeps and observe everything they produced.
|
||||
private static class Harness
|
||||
{
|
||||
public static (GrpcSecretSyncService Service, RecordingListLogger Logs, ISecretsHubSyncStatus Status) Create(
|
||||
FakeTimeProvider time,
|
||||
TimeSpan? inSyncLogInterval = null,
|
||||
bool manifestUnchanged = false,
|
||||
int failFirst = 0)
|
||||
{
|
||||
var logger = new RecordingListLogger();
|
||||
var status = new SecretsHubSyncStatus();
|
||||
|
||||
ISecretsHubReader hub = failFirst > 0
|
||||
? new SequencedFailThenEmptyHubReader(failFirst)
|
||||
: new EmptyHubReader();
|
||||
|
||||
IOptions<SecretsGrpcHubClientOptions> options = Options.Create(new SecretsGrpcHubClientOptions
|
||||
{
|
||||
Endpoint = "https://central:5001",
|
||||
BearerToken = "unused-by-the-fake",
|
||||
SyncInterval = TimeSpan.FromHours(1),
|
||||
InSyncLogInterval = inSyncLogInterval ?? TimeSpan.FromHours(1),
|
||||
});
|
||||
|
||||
// manifestUnchanged has no separate fake to select: both readers above always answer an
|
||||
// empty (therefore unchanged, nothing-to-pull) manifest once they are done failing, so
|
||||
// the parameter exists to make each call site self-documenting rather than to branch on.
|
||||
var svc = new GrpcSecretSyncService(
|
||||
new NoOpLocalStore(),
|
||||
hub,
|
||||
cacheInvalidator: null,
|
||||
options,
|
||||
logger,
|
||||
time,
|
||||
status);
|
||||
|
||||
return (svc, logger, status);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>A hub with nothing in it — every sweep is in-sync with zero rows to pull.</summary>
|
||||
private sealed class EmptyHubReader : ISecretsHubReader
|
||||
{
|
||||
public Task<IReadOnlyList<SecretManifestEntry>> GetManifestAsync(CancellationToken ct) =>
|
||||
Task.FromResult<IReadOnlyList<SecretManifestEntry>>([]);
|
||||
|
||||
public Task<IReadOnlyList<StoredSecret>> GetManyAsync(
|
||||
IReadOnlyList<SecretName> names, CancellationToken ct) =>
|
||||
Task.FromResult<IReadOnlyList<StoredSecret>>([]);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fails the manifest call with a transport-shaped exception for the first
|
||||
/// <paramref name="failuresBeforeSuccess"/> calls, then behaves exactly like
|
||||
/// <see cref="EmptyHubReader"/> forever after.
|
||||
/// </summary>
|
||||
private sealed class SequencedFailThenEmptyHubReader(int failuresBeforeSuccess) : ISecretsHubReader
|
||||
{
|
||||
private int _remaining = failuresBeforeSuccess;
|
||||
|
||||
public Task<IReadOnlyList<SecretManifestEntry>> GetManifestAsync(CancellationToken ct)
|
||||
{
|
||||
if (_remaining > 0)
|
||||
{
|
||||
_remaining--;
|
||||
return Task.FromException<IReadOnlyList<SecretManifestEntry>>(
|
||||
new InvalidOperationException("hub unreachable"));
|
||||
}
|
||||
|
||||
return Task.FromResult<IReadOnlyList<SecretManifestEntry>>([]);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<StoredSecret>> GetManyAsync(
|
||||
IReadOnlyList<SecretName> names, CancellationToken ct) =>
|
||||
Task.FromResult<IReadOnlyList<StoredSecret>>([]);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A local store that is never actually exercised by these scenarios (the hub manifest is
|
||||
/// always empty, so the reconciler's pull set is always empty and none of these members are
|
||||
/// called) beyond <see cref="GetManifestAsync"/>, which the reconciler always calls to compute
|
||||
/// the pull set.
|
||||
/// </summary>
|
||||
private sealed class NoOpLocalStore : ISecretStore
|
||||
{
|
||||
public Task<StoredSecret?> GetAsync(SecretName name, CancellationToken ct) =>
|
||||
throw new NotSupportedException("not exercised by the logging test scenarios.");
|
||||
|
||||
public Task UpsertAsync(StoredSecret row, CancellationToken ct) =>
|
||||
throw new NotSupportedException("not exercised by the logging test scenarios.");
|
||||
|
||||
public Task<bool> DeleteAsync(SecretName name, string? actor, CancellationToken ct) =>
|
||||
throw new NotSupportedException("not exercised by the logging test scenarios.");
|
||||
|
||||
public Task<IReadOnlyList<SecretMetadata>> ListAsync(bool includeDeleted, CancellationToken ct) =>
|
||||
throw new NotSupportedException("not exercised by the logging test scenarios.");
|
||||
|
||||
public Task<IReadOnlyList<SecretManifestEntry>> GetManifestAsync(CancellationToken ct) =>
|
||||
Task.FromResult<IReadOnlyList<SecretManifestEntry>>([]);
|
||||
|
||||
public Task ApplyReplicatedAsync(StoredSecret row, CancellationToken ct) =>
|
||||
throw new NotSupportedException("not exercised by the logging test scenarios.");
|
||||
|
||||
public Task<bool> ApplyRewrapAsync(
|
||||
StoredSecret rewrappedRow, byte[] expectedCurrentWrappedDek, CancellationToken ct) =>
|
||||
throw new NotSupportedException("not exercised by the logging test scenarios.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>A clock a test advances by hand, so an hourly heartbeat can be tested without waiting an hour.</summary>
|
||||
/// <remarks>
|
||||
/// Hand-rolled rather than taken from <c>Microsoft.Extensions.TimeProvider.Testing</c>, matching
|
||||
/// this project's existing convention (see <c>HubFakeTimeProvider</c> in
|
||||
/// <c>SecretsHubAuthInterceptorTests.cs</c>) of not taking that package dependency for one small type.
|
||||
/// </remarks>
|
||||
internal sealed class FakeTimeProvider : TimeProvider
|
||||
{
|
||||
private DateTimeOffset _now;
|
||||
|
||||
/// <summary>Creates a clock starting at the current wall-clock time.</summary>
|
||||
public FakeTimeProvider() : this(DateTimeOffset.UtcNow)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>Creates a clock starting at <paramref name="now"/>.</summary>
|
||||
/// <param name="now">The instant the clock starts at.</param>
|
||||
public FakeTimeProvider(DateTimeOffset now) => _now = now;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override DateTimeOffset GetUtcNow() => _now;
|
||||
|
||||
/// <summary>Moves the clock forward by <paramref name="delta"/>.</summary>
|
||||
/// <param name="delta">The amount of time to advance.</param>
|
||||
public void Advance(TimeSpan delta) => _now += delta;
|
||||
}
|
||||
|
||||
/// <summary>Captures every log entry's level, event id, and formatted message, across all levels.</summary>
|
||||
/// <remarks>
|
||||
/// Distinct from the existing <c>RecordingLogger<T></c> in <c>TestDoubles.cs</c>, which only
|
||||
/// captures Information/Warning and neither type captures <see cref="EventId"/> — this suite needs
|
||||
/// Debug-level entries and event-id assertions that the existing double does not support, and
|
||||
/// widening it would risk the tests that already depend on its narrower shape.
|
||||
/// </remarks>
|
||||
internal sealed class RecordingListLogger : ILogger<GrpcSecretSyncService>
|
||||
{
|
||||
/// <summary>Every entry logged, in order.</summary>
|
||||
public List<(LogLevel Level, EventId EventId, string Message)> Entries { get; } = [];
|
||||
|
||||
/// <inheritdoc/>
|
||||
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public bool IsEnabled(LogLevel logLevel) => true;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void Log<TState>(
|
||||
LogLevel logLevel,
|
||||
EventId eventId,
|
||||
TState state,
|
||||
Exception? exception,
|
||||
Func<TState, Exception?, string> formatter)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(formatter);
|
||||
|
||||
Entries.Add((logLevel, eventId, formatter(state, exception)));
|
||||
}
|
||||
}
|
||||
+7
-2
@@ -291,11 +291,16 @@ public sealed class GrpcSecretSyncServiceTests : IDisposable
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_quiet_sweep_logs_nothing_at_information()
|
||||
public async Task A_quiet_sweep_logs_the_in_sync_heartbeat_once_but_no_warning()
|
||||
{
|
||||
// A quiet (nothing-to-pull) sweep no longer logs NOTHING at Information: the first-ever
|
||||
// in-sync sweep always logs the once-per-InSyncLogInterval heartbeat (EventId 5), because
|
||||
// "never logged before" is one of the two conditions that fires it. See
|
||||
// GrpcSecretSyncServiceLoggingTests for the interval-gated repeat behaviour on later sweeps.
|
||||
// What a quiet sweep still never does is warn.
|
||||
await RunOneStartupSweepAsync(() => _hub.ManifestCallCount > 0);
|
||||
|
||||
Assert.Equal(0, _logger.InformationCount);
|
||||
Assert.Equal(1, _logger.InformationCount);
|
||||
Assert.Equal(0, _logger.WarningCount);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user