00a804e6d9
Register row 50. Row 45 (fixed 9e769735) was a live captive dependency —
NotificationOutboxActor cached delivery adapters resolved from an actor-lifetime
IServiceScope, so MaxParallelDeliveries concurrent deliveries shared one EF
DbContext and central-b logged EF's "second operation was started on this context
instance" continuously. Actors take the ROOT IServiceProvider rather than typed
services, which is invisible to build-time analysis, so the bug class had no
mechanical guard at all. This adds both of the register's mitigations; neither
subsumes the other.
1. ValidateScopes + ValidateOnBuild in Development, on BOTH branches, via
ServiceProviderValidation.Configure. WebApplication.CreateBuilder already sets
both flags to IsDevelopment(), so on paper this restates a default — it is
written out because that default is silent (a future move off CreateBuilder
drops it with no diagnostic) and because this host carries three
environment-ish signals (SCADABRIDGE_CONFIG, DOTNET_ENVIRONMENT,
ASPNETCORE_ENVIRONMENT), so "is this node in Development" is not obvious at the
call site. Production stays unvalidated deliberately: a validation failure is
fail-closed, and CI now proves what a production node will not check.
This surfaced two REAL latent violations on the SITE role — not captive
dependencies but unconstructible descriptors, which fail ValidateOnBuild for
the entire graph and are therefore what kept the site branch from being
validatable at all (SiteHealthEndpointTests had pinned UseEnvironment
("Production") to work around exactly this):
- ReconcileService (scoped, needs the central-only IDeploymentManagerRepository)
moved out of the shared AddCommunication() into a new AddCentralCommunication().
- AuditLogKpiSampleSource (scoped IKpiSampleSource, needs the central-only
IAuditLogRepository) moved out of the shared AddAuditLog() into a new
AddAuditLogCentralKpi().
Both were registered on sites under the argument "harmless — only the central
actor ever resolves it". True of the runtime, false of the container: a
registration nothing can build is what stops the validator being usable, and it
masks whatever real defect the validator would have found. Both are now
central-only and SiteHealthEndpointTests boots the site branch validated again.
2. CaptiveDependencyGuard: a reflection scan over every actor class in the shipped
assemblies (28 today; the reference graph is walked from the Host assembly
because referenced assemblies load lazily). Fails on any instance field — auto-
property backing fields included, they are ordinary private fields — typed as
IServiceScope, as a DbContext, or as a service the REAL composition roots
register Scoped. The scoped set is derived from the built Central and Site
containers rather than hand-listed, so a lifetime change automatically re-scopes
the check. IServiceProvider / IServiceScopeFactory fields are explicitly allowed:
that IS the correct per-operation-scope pattern.
No live violation exists — every actor opens a scope per operation. Two fields
are allowlisted with their safety argument rather than exempted silently:
AuditLogIngestActor._injectedRepository and SiteCallAuditActor._injectedRepository
are scoped repository types assigned ONLY by explicitly test-mode constructors,
null under production wiring (AkkaHostedService uses the IServiceProvider
overload in both cases), and SiteCallAuditActor additionally wraps its injected
instance in a SerializedRepository. The shape is one constructor call away from
being row 45 again, so it is recorded, not dismissed.
Tests: 10 new. Six permanent self-tests of the guard, including positive controls
that reproduce the pre-row-45 shape (an actor caching a scoped service, an
IServiceScope, a DbContext, and a scoped auto-property) and a negative control for
the allowed root-provider pattern — a reflection guard that matches nothing passes
just as green as one that works. Plus an actor-count floor with cross-assembly
spot-checks, an allowlist-staleness test that fails any entry no longer matching a
flagged field, and the two per-role scans. Verified non-vacuous by emptying the
allowlist: the Central scan then names both real fields. Two composition-root
build tests force both validation flags on per role regardless of environment.
Doc: REQ-HOST-11 in Component-Host.md records all three rules and their guards.
587 lines
35 KiB
C#
587 lines
35 KiB
C#
using Microsoft.Extensions.Configuration;
|
|
using Microsoft.Extensions.DependencyInjection;
|
|
using Microsoft.Extensions.DependencyInjection.Extensions;
|
|
using Microsoft.Extensions.Hosting;
|
|
using Microsoft.Extensions.Logging;
|
|
using ZB.MOM.WW.Audit;
|
|
using ZB.MOM.WW.Configuration;
|
|
using ZB.MOM.WW.ScadaBridge.AuditLog.Central;
|
|
using ZB.MOM.WW.ScadaBridge.AuditLog.Configuration;
|
|
using ZB.MOM.WW.ScadaBridge.AuditLog.Kpi;
|
|
using ZB.MOM.WW.ScadaBridge.AuditLog.Payload;
|
|
using ZB.MOM.WW.ScadaBridge.AuditLog.Redaction;
|
|
using ZB.MOM.WW.ScadaBridge.AuditLog.Site;
|
|
using ZB.MOM.WW.ScadaBridge.AuditLog.Site.Telemetry;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Kpi;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
|
|
using IAuditWriter = ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services.IAuditWriter;
|
|
|
|
namespace ZB.MOM.WW.ScadaBridge.AuditLog;
|
|
|
|
/// <summary>
|
|
/// Composition root for the Audit Log component.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// Registered <see cref="AuditLogOptions"/> + the validator. Extends the
|
|
/// surface with the site-side writer chain
|
|
/// (<see cref="SqliteAuditWriter"/> + <see cref="RingBufferFallback"/> +
|
|
/// <see cref="FallbackAuditWriter"/>) and the telemetry collaborators
|
|
/// (<see cref="ISiteAuditQueue"/>, <see cref="ISiteStreamAuditClient"/>,
|
|
/// <see cref="IAuditWriteFailureCounter"/>, <see cref="SiteAuditTelemetryOptions"/>,
|
|
/// <see cref="SqliteAuditWriterOptions"/>).
|
|
/// </para>
|
|
/// <para>
|
|
/// Audit Log sits alongside Notification Outbox and Site Call
|
|
/// Audit. <c>IAuditLogRepository</c> is registered by
|
|
/// <c>ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.ServiceCollectionExtensions.AddConfigurationDatabase</c>,
|
|
/// so the caller (the Host on the central node) must also call that.
|
|
/// </para>
|
|
/// </remarks>
|
|
public static class ServiceCollectionExtensions
|
|
{
|
|
/// <summary>Configuration section bound to <see cref="AuditLogOptions"/>.</summary>
|
|
public const string ConfigSectionName = "AuditLog";
|
|
|
|
/// <summary>Configuration section bound to <see cref="SqliteAuditWriterOptions"/>.</summary>
|
|
public const string SiteWriterSectionName = "AuditLog:SiteWriter";
|
|
|
|
/// <summary>Configuration section bound to <see cref="SiteAuditTelemetryOptions"/>.</summary>
|
|
public const string SiteTelemetrySectionName = "AuditLog:SiteTelemetry";
|
|
|
|
/// <summary>Configuration section bound to <see cref="AuditLogPartitionMaintenanceOptions"/>.</summary>
|
|
public const string PartitionMaintenanceSectionName = "AuditLog:PartitionMaintenance";
|
|
|
|
/// <summary>Configuration section bound to <see cref="ZB.MOM.WW.ScadaBridge.AuditLog.Central.AuditLogPurgeOptions"/>.</summary>
|
|
public const string PurgeSectionName = "AuditLog:Purge";
|
|
|
|
/// <summary>Configuration section bound to <see cref="ZB.MOM.WW.ScadaBridge.AuditLog.Central.SiteAuditReconciliationOptions"/>.</summary>
|
|
public const string ReconciliationSectionName = "AuditLog:Reconciliation";
|
|
|
|
/// <summary>
|
|
/// Registers the Audit Log component services: options, the site
|
|
/// SQLite writer chain (primary + ring fallback + failure-counter sink),
|
|
/// and the site-→central telemetry collaborators. Idempotent re-registration
|
|
/// is not supported; call this exactly once per <see cref="IServiceCollection"/>.
|
|
/// </summary>
|
|
/// <param name="services">The service collection to register into.</param>
|
|
/// <param name="config">Application configuration used to bind <see cref="AuditLogOptions"/> and related options sections.</param>
|
|
/// <returns>The same <see cref="IServiceCollection"/> for chaining.</returns>
|
|
public static IServiceCollection AddAuditLog(this IServiceCollection services, IConfiguration config)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(services);
|
|
ArgumentNullException.ThrowIfNull(config);
|
|
|
|
// Top-level AuditLogOptions + validator (redaction policy, payload caps, etc.).
|
|
// Collapsed onto the shared ZB.MOM.WW.Configuration helper: it binds the
|
|
// "AuditLog" section, registers the validator, and enables ValidateOnStart in
|
|
// one call. Same section path as before; AddAuditLog is call-once per
|
|
// collection, and the helper's TryAddEnumerable is idempotent for the
|
|
// validator (a strict improvement over the previous AddSingleton).
|
|
services.AddValidatedOptions<AuditLogOptions, AuditLogOptionsValidator>(config, ConfigSectionName);
|
|
|
|
// The canonical IAuditRedactor is wired as
|
|
// ScadaBridgeAuditRedactor — same truncation + header / body /
|
|
// SQL-parameter redaction as the original pipeline, applied between
|
|
// event construction and persistence. Singleton — stateless; the
|
|
// IOptionsMonitor dependency picks up hot reloads on its own.
|
|
services.AddSingleton<IAuditRedactor, ScadaBridgeAuditRedactor>();
|
|
|
|
// Per-stage redactor-failure counter. NoOp default; this binding is
|
|
// later replaced with the Site Health Monitoring bridge that surfaces
|
|
// failures as AuditRedactionFailure on the site health report.
|
|
services.TryAddSingleton<IAuditRedactionFailureCounter, NoOpAuditRedactionFailureCounter>();
|
|
|
|
// Site writer + telemetry options bindings — now bind-and-validate via
|
|
// the shared helper so a bad site config fails fast at boot (NF4). The
|
|
// helper's GetSection(sectionPath) still resolves the section explicitly
|
|
// (NOT BindConfiguration), preserving the partial-config-root rationale:
|
|
// the caller's IConfiguration may be a test stub anchored on the AuditLog
|
|
// section's parent rather than the application root.
|
|
services.AddValidatedOptions<SqliteAuditWriterOptions, SqliteAuditWriterOptionsValidator>(
|
|
config, SiteWriterSectionName);
|
|
services.AddValidatedOptions<SiteAuditTelemetryOptions, SiteAuditTelemetryOptionsValidator>(
|
|
config, SiteTelemetrySectionName);
|
|
// Retention purge options (the SiteAuditRetentionService hosted job is
|
|
// registered site-only in AddAuditLogHealthMetricsBridge). The binding
|
|
// here is inert on central — no hosted service consumes it there — but
|
|
// the defaults validate clean, so eager validation on the central root is
|
|
// harmless.
|
|
services.AddValidatedOptions<SiteAuditRetentionOptions, SiteAuditRetentionOptionsValidator>(
|
|
config, SiteAuditRetentionOptions.SectionName);
|
|
|
|
// SqliteAuditWriter is a singleton with a single owned SqliteConnection
|
|
// and a background writer Task; multiple instances would race on the
|
|
// same file. Registered concretely so the ISiteAuditQueue + IAuditWriter
|
|
// forwards below resolve to the same instance — the actor must observe
|
|
// the writes made via the hot-path interface.
|
|
services.AddSingleton<SqliteAuditWriter>();
|
|
services.AddSingleton<ISiteAuditQueue>(sp => sp.GetRequiredService<SqliteAuditWriter>());
|
|
|
|
// RingBufferFallback: drop-oldest in-memory ring used by
|
|
// FallbackAuditWriter when the primary SQLite writer throws. Default
|
|
// capacity is fine (1024).
|
|
services.AddSingleton<RingBufferFallback>();
|
|
|
|
// IAuditWriteFailureCounter: NoOp default. This binding is later
|
|
// overridden with the real Site Health Monitoring counter. Registered
|
|
// before FallbackAuditWriter so the factory can resolve it.
|
|
services.AddSingleton<IAuditWriteFailureCounter, NoOpAuditWriteFailureCounter>();
|
|
|
|
// The script-thread surface is FallbackAuditWriter (primary + ring +
|
|
// counter), not the raw SqliteAuditWriter — primary failures must NEVER
|
|
// abort the user-facing action.
|
|
// The canonical IAuditRedactor singleton above is wired
|
|
// through the factory so every event written through this surface is
|
|
// truncated + redacted before it hits SQLite (and the ring on
|
|
// failure).
|
|
services.AddSingleton<IAuditWriter>(sp => new FallbackAuditWriter(
|
|
primary: sp.GetRequiredService<SqliteAuditWriter>(),
|
|
ring: sp.GetRequiredService<RingBufferFallback>(),
|
|
failureCounter: sp.GetRequiredService<IAuditWriteFailureCounter>(),
|
|
logger: sp.GetRequiredService<ILogger<FallbackAuditWriter>>(),
|
|
redactor: sp.GetRequiredService<IAuditRedactor>()));
|
|
|
|
// ISiteStreamAuditClient: NoOp default. This binding remains correct for
|
|
// central/test composition roots that have no SiteCommunicationActor.
|
|
// The real implementation is SiteCommunicationAuditClient, which pushes
|
|
// audit telemetry to central via the site's
|
|
// SiteCommunicationActor — the Host wires it directly into the
|
|
// SiteAuditTelemetryActor's Props.Create call for site roles (it cannot
|
|
// be a DI singleton because it needs the SiteCommunicationActor IActorRef,
|
|
// created during Akka bootstrap, not at DI-composition time).
|
|
services.AddSingleton<ISiteStreamAuditClient, NoOpSiteStreamAuditClient>();
|
|
|
|
// Site-side dual emitter for cached-call lifecycle
|
|
// telemetry. ScriptRuntimeContext.ExternalSystem.CachedCall /
|
|
// Database.CachedWrite resolves this through DI and pushes one combined
|
|
// packet per lifecycle event; the forwarder writes the audit half
|
|
// through IAuditWriter and the operational half through the
|
|
// IOperationTrackingStore. The audit writer is always wired (the
|
|
// chain above); the operational tracking store is SITE-ONLY (registered
|
|
// by ZB.MOM.WW.ScadaBridge.SiteRuntime). On a Central composition root the tracking
|
|
// store has no registration, so the factory resolves it with GetService
|
|
// (returning null) — the forwarder degrades to "audit-only" emission,
|
|
// mirroring the lazy IAuditWriter chain established above.
|
|
services.AddSingleton<ICachedCallTelemetryForwarder>(sp =>
|
|
new CachedCallTelemetryForwarder(
|
|
sp.GetRequiredService<IAuditWriter>(),
|
|
sp.GetService<ZB.MOM.WW.ScadaBridge.Commons.Interfaces.IOperationTrackingStore>(),
|
|
sp.GetRequiredService<ILogger<CachedCallTelemetryForwarder>>(),
|
|
// INodeIdentityProvider is now required across
|
|
// every consumer in AddAuditLog. The Host's
|
|
// SiteServiceRegistration registers it as a singleton on both
|
|
// site and central paths, and the AddAuditLogTests fixture provides a
|
|
// FakeNodeIdentityProvider; a silent GetService() that
|
|
// returned null would mask a future composition root that
|
|
// forgot to register the provider.
|
|
sp.GetRequiredService<INodeIdentityProvider>()));
|
|
|
|
// Bridge the store-and-forward retry-loop observer hook
|
|
// to the cached-call forwarder so per-attempt + terminal telemetry
|
|
// emitted from the S&F retry sweep lands on the same SQLite hot-path
|
|
// as the script-thread CachedSubmit row. Registered as a singleton
|
|
// and also bound to ICachedCallLifecycleObserver so AddStoreAndForward
|
|
// can resolve it through DI.
|
|
// SourceNode-stamping: factory-resolved so the
|
|
// INodeIdentityProvider singleton can be threaded through — the
|
|
// bridge stamps SiteCallOperational.SourceNode from
|
|
// INodeIdentityProvider.NodeName on every cached-call lifecycle row.
|
|
// The provider is resolved with GetRequiredService —
|
|
// SiteServiceRegistration.BindSharedOptions registers it on both
|
|
// site and central paths, so a missing registration is a
|
|
// composition-root bug, not a silent null-SourceNode degradation.
|
|
services.AddSingleton<CachedCallLifecycleBridge>(sp => new CachedCallLifecycleBridge(
|
|
sp.GetRequiredService<ICachedCallTelemetryForwarder>(),
|
|
sp.GetRequiredService<ILogger<CachedCallLifecycleBridge>>(),
|
|
// Required, matches the other consumers in this
|
|
// composition root — the provider is always registered by
|
|
// SiteServiceRegistration.
|
|
sp.GetRequiredService<INodeIdentityProvider>()));
|
|
services.AddSingleton<ICachedCallLifecycleObserver>(
|
|
sp => sp.GetRequiredService<CachedCallLifecycleBridge>());
|
|
|
|
// Central audit-write failure counter — NoOp default
|
|
// for site/test composition roots that don't wire the central health
|
|
// snapshot. AddAuditLogCentralMaintenance below replaces this binding
|
|
// with the AuditCentralHealthSnapshot implementation so increments
|
|
// surface on the central dashboard.
|
|
services.TryAddSingleton<ICentralAuditWriteFailureCounter, NoOpCentralAuditWriteFailureCounter>();
|
|
|
|
// Inbound body-ceiling hit counter — NoOp default for
|
|
// site/test roots. AddAuditLogCentralMaintenance replaces this binding
|
|
// with the AuditCentralHealthSnapshot implementation so ceiling-hit
|
|
// counts surface on the central dashboard alongside write-failure and
|
|
// redaction-failure counters.
|
|
services.TryAddSingleton<IAuditInboundCeilingHitsCounter, NoOpAuditInboundCeilingHitsCounter>();
|
|
|
|
// AuditLog purge-failure counter — NoOp default for site/test
|
|
// composition roots. AddAuditLogCentralMaintenance below replaces this binding
|
|
// with the AuditCentralHealthSnapshot implementation so partition/channel purge
|
|
// failures surface on the central dashboard alongside the write-failure counter.
|
|
services.TryAddSingleton<IAuditPurgeFailureCounter, NoOpAuditPurgeFailureCounter>();
|
|
|
|
// Central direct-write audit writer used by
|
|
// NotificationOutboxActor and Inbound API to
|
|
// emit AuditLog rows that originate ON central, not via site telemetry.
|
|
// Singleton — the writer is stateless; its per-call scope opens a fresh
|
|
// IAuditLogRepository (a SCOPED EF Core service registered by
|
|
// ZB.MOM.WW.ScadaBridge.ConfigurationDatabase). The interface (ICentralAuditWriter)
|
|
// is intentionally distinct from IAuditWriter so site composition roots
|
|
// do not accidentally bind it; central composition roots that include
|
|
// AddConfigurationDatabase get a working implementation transparently.
|
|
// Wire the canonical IAuditRedactor into the factory so
|
|
// NotificationOutboxActor + Inbound API rows are truncated + redacted
|
|
// before they hit MS SQL.
|
|
// Also wire the ICentralAuditWriteFailureCounter
|
|
// so swallowed repo throws bump the central health counter.
|
|
services.AddSingleton<ICentralAuditWriter>(sp => new CentralAuditWriter(
|
|
sp,
|
|
sp.GetRequiredService<ILogger<CentralAuditWriter>>(),
|
|
sp.GetRequiredService<IAuditRedactor>(),
|
|
sp.GetRequiredService<ICentralAuditWriteFailureCounter>(),
|
|
// SourceNode-stamping: wire the local node identity so
|
|
// central-origin rows (Notification Outbox dispatch, Inbound API)
|
|
// carry the writing node's identifier when the caller hasn't
|
|
// already supplied one. GetRequiredService — the production
|
|
// composition root in SiteServiceRegistration registers the
|
|
// provider as a singleton on both site and central paths.
|
|
sp.GetRequiredService<INodeIdentityProvider>()));
|
|
|
|
return services;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Registers the Audit Log's KPI sample source. CENTRAL-ONLY — call from the Central
|
|
/// composition root only, alongside <c>AddKpiHistory</c>.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// KPI History & Trends: the AuditLog source the central KPI recorder enumerates each
|
|
/// sampling pass to snapshot Global-scope Audit Log KPIs (volume / error rate / backlog)
|
|
/// into the <c>KpiSample</c> history table. Scoped to match its <c>IAuditLogRepository</c>
|
|
/// dependency — a SCOPED EF Core service; the recorder opens a fresh scope per sampling
|
|
/// pass. TryAdd-Enumerable so multiple sources can register the same interface
|
|
/// (NotificationOutbox + SiteCallAudit + SiteHealth add their own) without any one
|
|
/// composition root clobbering another, and re-registration is a no-op if this were ever
|
|
/// called twice.
|
|
/// </para>
|
|
/// <para>
|
|
/// Split out of <see cref="AddAuditLog"/> by deferred register row 50. Both the KPI
|
|
/// recorder and <c>IAuditLogRepository</c> are central-only, so on a site node this
|
|
/// registration was unconstructible — enough on its own to fail <c>ValidateOnBuild</c> for
|
|
/// the entire site graph and so to keep scope validation switched off there. The site half
|
|
/// of <see cref="AddAuditLog"/> (the SQLite writer chain) is unaffected and stays shared.
|
|
/// </para>
|
|
/// </remarks>
|
|
/// <param name="services">The DI service collection to register services into.</param>
|
|
/// <returns>The same <see cref="IServiceCollection"/> to allow chaining.</returns>
|
|
public static IServiceCollection AddAuditLogCentralKpi(this IServiceCollection services)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(services);
|
|
|
|
services.TryAddEnumerable(
|
|
ServiceDescriptor.Scoped<IKpiSampleSource, AuditLogKpiSampleSource>());
|
|
|
|
return services;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Swap the default
|
|
/// <see cref="NoOpAuditWriteFailureCounter"/> and
|
|
/// <see cref="NoOpAuditRedactionFailureCounter"/> registrations for the
|
|
/// real <see cref="HealthMetricsAuditWriteFailureCounter"/> /
|
|
/// <see cref="HealthMetricsAuditRedactionFailureCounter"/> bridges so the
|
|
/// FallbackAuditWriter primary-failure counter AND the
|
|
/// <see cref="ScadaBridgeAuditRedactor"/> redactor-failure counter both surface in the
|
|
/// site health report payload as
|
|
/// <c>SiteHealthReport.SiteAuditWriteFailures</c> +
|
|
/// <c>SiteHealthReport.AuditRedactionFailure</c>.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// Must be called AFTER both <see cref="AddAuditLog"/> (registers the
|
|
/// NoOp defaults this method replaces) and
|
|
/// <c>ZB.MOM.WW.ScadaBridge.HealthMonitoring.ServiceCollectionExtensions.AddHealthMonitoring</c>
|
|
/// or <c>AddSiteHealthMonitoring</c> (registers the
|
|
/// <see cref="ISiteHealthCollector"/> the bridges depend on). Resolving
|
|
/// <see cref="IAuditWriteFailureCounter"/> or
|
|
/// <see cref="IAuditRedactionFailureCounter"/> without the latter throws
|
|
/// <see cref="InvalidOperationException"/> at <c>GetRequiredService</c>
|
|
/// time — by design, since a silent NoOp would mask a misconfiguration.
|
|
/// </para>
|
|
/// <para>
|
|
/// Idempotent — a sentinel check on the
|
|
/// <see cref="SiteAuditBacklogReporter"/> hosted-service descriptor
|
|
/// short-circuits subsequent calls so the hosted service is not
|
|
/// double-registered (AddHostedService has no TryAdd variant).
|
|
/// </para>
|
|
/// <para>
|
|
/// Site-side only: the central composition root keeps the NoOp
|
|
/// defaults; the central health-metric surface that would expose
|
|
/// <c>AuditRedactionFailure</c> next to the existing central counters
|
|
/// ships later.
|
|
/// </para>
|
|
/// </remarks>
|
|
/// <param name="services">The service collection to register into.</param>
|
|
/// <returns>The same <see cref="IServiceCollection"/> for chaining.</returns>
|
|
public static IServiceCollection AddAuditLogHealthMetricsBridge(this IServiceCollection services)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(services);
|
|
|
|
// Guard against double-registration. AddHostedService is
|
|
// additive (no TryAdd variant) so a second call without this sentinel
|
|
// would spin up a second SiteAuditBacklogReporter, doubling the 30 s
|
|
// SQL probe rate and racing on the ISiteHealthCollector snapshot. The
|
|
// SiteAuditBacklogReporter descriptor is the discriminator: it's only
|
|
// registered by this helper, so its presence proves the bridge has
|
|
// already been wired.
|
|
if (services.Any(d => d.ImplementationType == typeof(SiteAuditBacklogReporter)))
|
|
{
|
|
return services;
|
|
}
|
|
|
|
services.Replace(
|
|
ServiceDescriptor.Singleton<IAuditWriteFailureCounter, HealthMetricsAuditWriteFailureCounter>());
|
|
services.Replace(
|
|
ServiceDescriptor.Singleton<IAuditRedactionFailureCounter, HealthMetricsAuditRedactionFailureCounter>());
|
|
// The site-side backlog reporter polls the
|
|
// SqliteAuditWriter every 30 s and pushes the snapshot into the
|
|
// collector so the next SiteHealthReport carries a fresh
|
|
// SiteAuditBacklog field. Registered alongside the other site-only
|
|
// metric bridges so AddAuditLog (which runs on central too) stays
|
|
// free of hosted-service registrations that would resolve a missing
|
|
// ISiteHealthCollector on central.
|
|
services.AddHostedService<SiteAuditBacklogReporter>();
|
|
// Site-only retention purge: runs ISiteAuditQueue.PurgeExpiredAsync on a
|
|
// daily timer so the ephemeral site SQLite store does not grow unbounded.
|
|
// Registered here (not in AddAuditLog, which also runs on central) so the
|
|
// hosted service only starts on site composition roots. Guarded by the same
|
|
// SiteAuditBacklogReporter sentinel above against double-registration.
|
|
services.AddHostedService<SiteAuditRetentionService>();
|
|
return services;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Central-only registration for the
|
|
/// <see cref="AuditLogPartitionMaintenanceService"/> hosted service plus
|
|
/// its <see cref="AuditLogPartitionMaintenanceOptions"/> binding. Must be
|
|
/// called from the Central role's composition root (not from a site
|
|
/// composition root); the underlying <c>IPartitionMaintenance</c>
|
|
/// implementation is registered by <c>AddConfigurationDatabase</c> and
|
|
/// only exists on the central node.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// Separated from <see cref="AddAuditLog"/> because <c>AddAuditLog</c> is
|
|
/// also invoked from site composition roots — silently starting a
|
|
/// hosted service that resolves an unregistered dependency on a site
|
|
/// would fail every tick. Keeping the central-only registration in its
|
|
/// own helper preserves the "every <c>Add*</c> call is safe to issue
|
|
/// from any composition root" invariant.
|
|
/// </para>
|
|
/// </remarks>
|
|
/// <param name="services">The service collection to register into.</param>
|
|
/// <param name="config">Application configuration used to bind partition maintenance options.</param>
|
|
/// <returns>The same <see cref="IServiceCollection"/> for chaining.</returns>
|
|
public static IServiceCollection AddAuditLogCentralMaintenance(
|
|
this IServiceCollection services,
|
|
IConfiguration config)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(services);
|
|
ArgumentNullException.ThrowIfNull(config);
|
|
|
|
services.AddValidatedOptions<AuditLogPartitionMaintenanceOptions,
|
|
AuditLogPartitionMaintenanceOptionsValidator>(config, PartitionMaintenanceSectionName);
|
|
services.AddHostedService<AuditLogPartitionMaintenanceService>();
|
|
|
|
// I1 (review): bind the two central-singleton options HERE rather than in
|
|
// AddAuditLogCentralReconciliationClient. AkkaHostedService.RegisterCentralActors
|
|
// resolves IOptions<AuditLogPurgeOptions> / <SiteAuditReconciliationOptions>
|
|
// via GetRequiredService when it wires the AuditLogPurgeActor +
|
|
// SiteAuditReconciliationActor singletons; AddAuditLogCentralMaintenance is
|
|
// ALWAYS called on the central path (the reconciliation-client helper is the
|
|
// one that could in principle be dropped), so binding the options here means
|
|
// the singletons get a valid IOptions even if the gRPC-client helper is not
|
|
// wired — instead of a cryptic InvalidOperationException at GetRequiredService.
|
|
// Defaults are fine when the section is absent (24 h purge cadence /
|
|
// 5 min reconciliation tick); production exposes IntervalHours /
|
|
// ReconciliationIntervalSeconds only — the test-only *Override knobs are
|
|
// not intended to be set from config (see the options classes' remarks).
|
|
services.AddValidatedOptions<AuditLogPurgeOptions, AuditLogPurgeOptionsValidator>(
|
|
config, PurgeSectionName);
|
|
services.AddValidatedOptions<SiteAuditReconciliationOptions, SiteAuditReconciliationOptionsValidator>(
|
|
config, ReconciliationSectionName);
|
|
|
|
// Central health snapshot — a single object
|
|
// that owns the CentralAuditWriteFailures + AuditRedactionFailure
|
|
// Interlocked counters AND surfaces them on
|
|
// IAuditCentralHealthSnapshot. The same instance is bound to BOTH
|
|
// writer-side interfaces (ICentralAuditWriteFailureCounter +
|
|
// IAuditRedactionFailureCounter) so every central-side increment
|
|
// routes into the shared counters; site nodes keep their existing
|
|
// Site bridges (registered by AddAuditLogHealthMetricsBridge) so
|
|
// the same counter type does not shadow the site-side metric.
|
|
// The snapshot itself has no actor-system dependency — the
|
|
// per-site stalled latch is fed by SiteAuditTelemetryStalledTracker
|
|
// which the Akka bootstrap wires up after ActorSystem.Create returns
|
|
// (the tracker is NOT registered here because its construction
|
|
// requires ActorSystem, which is not a DI-resolvable singleton).
|
|
services.AddSingleton<AuditCentralHealthSnapshot>();
|
|
services.AddSingleton<IAuditCentralHealthSnapshot>(
|
|
sp => sp.GetRequiredService<AuditCentralHealthSnapshot>());
|
|
services.Replace(ServiceDescriptor.Singleton<ICentralAuditWriteFailureCounter>(
|
|
sp => sp.GetRequiredService<AuditCentralHealthSnapshot>()));
|
|
// Override the NoOp IAuditRedactionFailureCounter
|
|
// (registered by AddAuditLog) with the CentralAuditRedactionFailureCounter
|
|
// bridge so payload-filter throws on CentralAuditWriter /
|
|
// AuditLogIngestActor paths surface on the central dashboard. The
|
|
// bridge is a thin wrapper around the AuditCentralHealthSnapshot
|
|
// singleton so all central redactor failures route into the same
|
|
// counter as CentralAuditWriteFailures. The site composition root
|
|
// overrides this binding AGAIN via AddAuditLogHealthMetricsBridge —
|
|
// central nodes do not call that bridge, so this is the final
|
|
// binding on a central host. Mirrors the
|
|
// HealthMetricsAuditRedactionFailureCounter shape one-for-one.
|
|
services.Replace(ServiceDescriptor.Singleton<IAuditRedactionFailureCounter,
|
|
CentralAuditRedactionFailureCounter>());
|
|
// Replace the NoOp IAuditInboundCeilingHitsCounter with the
|
|
// AuditCentralHealthSnapshot so ceiling-hit counts surface on the
|
|
// central dashboard. Same singleton-forward pattern as
|
|
// ICentralAuditWriteFailureCounter above.
|
|
services.Replace(ServiceDescriptor.Singleton<IAuditInboundCeilingHitsCounter>(
|
|
sp => sp.GetRequiredService<AuditCentralHealthSnapshot>()));
|
|
// Replace the NoOp IAuditPurgeFailureCounter with the AuditCentralHealthSnapshot so
|
|
// partition/channel purge failures surface on the central dashboard. Same
|
|
// singleton-forward pattern as ICentralAuditWriteFailureCounter above (arch-review 04, S2).
|
|
services.Replace(ServiceDescriptor.Singleton<IAuditPurgeFailureCounter>(
|
|
sp => sp.GetRequiredService<AuditCentralHealthSnapshot>()));
|
|
|
|
return services;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Audit Log — central-only registration of the production
|
|
/// <see cref="IPullAuditEventsClient"/> (<see cref="GrpcPullAuditEventsClient"/>)
|
|
/// and its unary-call invoker (<see cref="GrpcPullAuditEventsInvoker"/>) used
|
|
/// by <see cref="SiteAuditReconciliationActor"/> to pull reconciliation
|
|
/// batches from each site over the <c>PullAuditEvents</c> gRPC RPC.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// Kept out of <see cref="AddAuditLog"/> — which also runs on site
|
|
/// composition roots — because the client dials sites and resolves
|
|
/// <see cref="ISiteEnumerator"/> (a central-only collaborator wired
|
|
/// alongside the reconciliation singleton). Folding it into
|
|
/// <see cref="AddAuditLog"/> would register a site-dialing client on every
|
|
/// site host, violating the "every <c>Add*</c> call is safe from any
|
|
/// composition root" invariant. This helper is the central analogue of
|
|
/// <see cref="AddAuditLogCentralMaintenance"/>.
|
|
/// </para>
|
|
/// <para>
|
|
/// The <see cref="GrpcPullAuditEventsInvoker"/> binds with default
|
|
/// <see cref="ZB.MOM.WW.ScadaBridge.Communication.CommunicationOptions"/>
|
|
/// keepalive unless an <c>IOptions<CommunicationOptions></c> is
|
|
/// already registered, in which case the configured timings flow through —
|
|
/// matching how <c>SiteStreamGrpcClientFactory</c> takes its keepalive from
|
|
/// the same options.
|
|
/// </para>
|
|
/// <para>
|
|
/// The production <see cref="ISiteEnumerator"/> (<see cref="SiteEnumerator"/>,
|
|
/// wrapping the scoped <c>ISiteRepository</c>) IS registered here — so the
|
|
/// <see cref="SiteAuditReconciliationActor"/> singleton wired in the Host can
|
|
/// resolve its enumerator + gRPC client from this central-only helper. Keeping
|
|
/// the enumerator on this central path preserves the "every <c>Add*</c> call is
|
|
/// safe from any composition root" invariant: a site host never calls this
|
|
/// helper, so it never registers a site-dialing enumerator. The
|
|
/// <see cref="AuditLogPurgeOptions"/> + <see cref="SiteAuditReconciliationOptions"/>
|
|
/// bindings live in <see cref="AddAuditLogCentralMaintenance"/> instead (I1
|
|
/// review fix) — that helper is unconditionally called on the central path, so
|
|
/// the two maintenance singletons get a valid <c>IOptions</c> even if this
|
|
/// gRPC-client helper is ever dropped.
|
|
/// </para>
|
|
/// </remarks>
|
|
/// <param name="services">The service collection to register into.</param>
|
|
/// <param name="config">Application configuration used to bind the gRPC client's communication options (purge + reconciliation options are bound by <see cref="AddAuditLogCentralMaintenance"/>).</param>
|
|
/// <returns>The same <see cref="IServiceCollection"/> for chaining.</returns>
|
|
public static IServiceCollection AddAuditLogCentralReconciliationClient(
|
|
this IServiceCollection services,
|
|
IConfiguration config)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(services);
|
|
ArgumentNullException.ThrowIfNull(config);
|
|
|
|
// Production ISiteEnumerator: projects the config-DB Site rows into the
|
|
// reconciliation targets the SiteAuditReconciliationActor polls. Scoped
|
|
// ISiteRepository is resolved per call inside the enumerator, so the
|
|
// singleton takes the ROOT provider (mirrors the per-tick scope pattern
|
|
// in SiteAuditReconciliationActor / AuditLogPurgeActor).
|
|
services.TryAddSingleton<ISiteEnumerator>(sp => new SiteEnumerator(sp));
|
|
|
|
// I1 (review): the AuditLogPurgeOptions / SiteAuditReconciliationOptions
|
|
// bindings moved to AddAuditLogCentralMaintenance — that helper is always
|
|
// called on the central path, so the two maintenance singletons resolve a
|
|
// valid IOptions even if this gRPC-client helper is ever dropped. Keep the
|
|
// ISiteEnumerator + gRPC client registrations here (they dial sites and are
|
|
// central-only by design).
|
|
|
|
// The invoker owns the per-endpoint GrpcChannel cache, so it must be a
|
|
// singleton — a fresh invoker per resolution would leak channels.
|
|
// Resolve CommunicationOptions if present (the central Host binds it),
|
|
// otherwise fall back to defaults so this helper stays standalone.
|
|
services.TryAddSingleton<GrpcPullAuditEventsInvoker>(sp =>
|
|
{
|
|
var options = sp
|
|
.GetService<Microsoft.Extensions.Options.IOptions<
|
|
ZB.MOM.WW.ScadaBridge.Communication.CommunicationOptions>>();
|
|
// The PSK provider is central-only and optional in DI, so GetService (not
|
|
// GetRequiredService): a host without one dials unauthenticated and the site
|
|
// refuses it, which is the fail-closed outcome we want rather than a
|
|
// resolution crash at composition time.
|
|
var psk = sp.GetService<ZB.MOM.WW.ScadaBridge.Communication.Grpc.ISitePskProvider>();
|
|
return options is null
|
|
? new GrpcPullAuditEventsInvoker(new ZB.MOM.WW.ScadaBridge.Communication.CommunicationOptions(), psk)
|
|
: new GrpcPullAuditEventsInvoker(options.Value, psk);
|
|
});
|
|
services.TryAddSingleton<GrpcPullAuditEventsClient.IPullAuditEventsInvoker>(
|
|
sp => sp.GetRequiredService<GrpcPullAuditEventsInvoker>());
|
|
|
|
services.TryAddSingleton<IPullAuditEventsClient>(sp => new GrpcPullAuditEventsClient(
|
|
sp.GetRequiredService<ISiteEnumerator>(),
|
|
sp.GetRequiredService<GrpcPullAuditEventsClient.IPullAuditEventsInvoker>(),
|
|
sp.GetRequiredService<ILogger<GrpcPullAuditEventsClient>>()));
|
|
|
|
// Site Call Audit reconciliation pull client — central-only, the
|
|
// sibling of the audit pull client above. Lives here (not in the
|
|
// SiteCallAudit project) so it can reuse the central-only
|
|
// ISiteEnumerator registered just above; SiteCallAudit does not
|
|
// reference AuditLog. The invoker owns the per-endpoint GrpcChannel
|
|
// cache, so it must be a singleton (a fresh invoker per resolution
|
|
// would leak channels). CommunicationOptions flow through when bound by
|
|
// the central Host, else defaults — mirrors the audit invoker.
|
|
services.TryAddSingleton<GrpcPullSiteCallsInvoker>(sp =>
|
|
{
|
|
var options = sp
|
|
.GetService<Microsoft.Extensions.Options.IOptions<
|
|
ZB.MOM.WW.ScadaBridge.Communication.CommunicationOptions>>();
|
|
// The PSK provider is central-only and optional in DI, so GetService (not
|
|
// GetRequiredService): a host without one dials unauthenticated and the site
|
|
// refuses it, which is the fail-closed outcome we want rather than a
|
|
// resolution crash at composition time.
|
|
var psk = sp.GetService<ZB.MOM.WW.ScadaBridge.Communication.Grpc.ISitePskProvider>();
|
|
return options is null
|
|
? new GrpcPullSiteCallsInvoker(new ZB.MOM.WW.ScadaBridge.Communication.CommunicationOptions(), psk)
|
|
: new GrpcPullSiteCallsInvoker(options.Value, psk);
|
|
});
|
|
services.TryAddSingleton<GrpcPullSiteCallsClient.IPullSiteCallsInvoker>(
|
|
sp => sp.GetRequiredService<GrpcPullSiteCallsInvoker>());
|
|
|
|
services.TryAddSingleton<IPullSiteCallsClient>(sp => new GrpcPullSiteCallsClient(
|
|
sp.GetRequiredService<ISiteEnumerator>(),
|
|
sp.GetRequiredService<GrpcPullSiteCallsClient.IPullSiteCallsInvoker>(),
|
|
sp.GetRequiredService<ILogger<GrpcPullSiteCallsClient>>()));
|
|
|
|
return services;
|
|
}
|
|
}
|