feat(host): register Audit Log #23 singletons with dedicated dispatcher (#23)

Wires Bundle E of the M2 site-sync pipeline:

- AddAuditLog extended to register the site writer chain (SqliteAuditWriter
  singleton + ISiteAuditQueue forward + RingBufferFallback + FallbackAuditWriter
  composing them) and the telemetry collaborators (SiteAuditTelemetryOptions,
  SqliteAuditWriterOptions, IAuditWriteFailureCounter NoOp default,
  ISiteStreamAuditClient NoOp default).
- AkkaHostedService central role: AuditLogIngestActor as ClusterSingletonManager
  (singleton name 'audit-log-ingest') + ClusterSingletonProxy, mirroring the
  Notification Outbox pattern. Proxy is offered to SiteStreamGrpcServer if it
  resolves (Site path only today; M6 reconciliation will host gRPC on central).
- AkkaHostedService site role: SiteAuditTelemetryActor (per-site, NOT a
  singleton because each site is its own cluster), bound to a dedicated
  audit-telemetry-dispatcher (ForkJoinDispatcher, 2 dedicated threads).
- Program.cs + SiteServiceRegistration.Configure call AddAuditLog on both roles.
- AuditLogIngestActor gains a second constructor that takes IServiceProvider so
  the cluster singleton can create a fresh scope per message — IAuditLogRepository
  is a scoped EF Core service and cannot be pre-resolved from the root. The
  IAuditLogRepository constructor remains for Bundle D's MSSQL-fixture tests.

NoOp ISiteStreamAuditClient is deliberate: no site→central gRPC channel exists
in M2 (sites talk to central via Akka ClusterClient; gRPC SiteStreamService is
hosted on sites for central→site streaming). M6 reconciliation introduces the
real gRPC site→central client + central-hosted gRPC server. Bundle H's
integration test substitutes a stub client directly via the actor's Props.

Tests:
- tests/ScadaLink.AuditLog.Tests/AddAuditLogTests.cs — 11 tests (was 3): writer
  singleton, IAuditWriter as FallbackAuditWriter, ISiteAuditQueue same-instance
  as SqliteAuditWriter, options bind round-trip, NoOp default assertions.
- tests/ScadaLink.Host.Tests/AkkaHostedServiceAuditWiringTests.cs (new) — 13
  tests: BuildHocon emits audit-telemetry-dispatcher block with the expected
  type/throughput/thread-count; Central composition root resolves the writer
  chain + options; Site composition root resolves the writer chain + options +
  NoOp client.

Verified: dotnet build clean, 23 test suites green (Host 194 + AuditLog 54).
This commit is contained in:
Joseph Doherty
2026-05-20 13:04:05 -04:00
parent 87cae88f92
commit 9bf1497f03
10 changed files with 765 additions and 42 deletions

View File

@@ -1,4 +1,5 @@
using Akka.Actor; using Akka.Actor;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using ScadaLink.Commons.Entities.Audit; using ScadaLink.Commons.Entities.Audit;
using ScadaLink.Commons.Interfaces.Repositories; using ScadaLink.Commons.Interfaces.Repositories;
@@ -28,12 +29,27 @@ namespace ScadaLink.AuditLog.Central;
/// inside <c>ReceiveAsync</c> does not restart the actor (which would also /// inside <c>ReceiveAsync</c> does not restart the actor (which would also
/// reset any in-flight state). /// reset any in-flight state).
/// </para> /// </para>
/// <para>
/// Two constructors exist for a deliberate reason: Bundle D's tests inject a
/// concrete <see cref="IAuditLogRepository"/> against a per-test MSSQL fixture
/// (the only way to verify the IngestedAtUtc stamp + duplicate-key idempotency
/// end to end), while Bundle E's host wiring registers the actor as a cluster
/// singleton and must therefore resolve the repository — which is a scoped EF
/// Core service — from a fresh DI scope per message. Mirroring the Notification
/// Outbox actor's pattern.
/// </para>
/// </remarks> /// </remarks>
public class AuditLogIngestActor : ReceiveActor public class AuditLogIngestActor : ReceiveActor
{ {
private readonly IAuditLogRepository _repository; private readonly IServiceProvider? _serviceProvider;
private readonly IAuditLogRepository? _injectedRepository;
private readonly ILogger<AuditLogIngestActor> _logger; private readonly ILogger<AuditLogIngestActor> _logger;
/// <summary>
/// Test-mode constructor — injects a concrete repository instance whose
/// lifetime exceeds the test, so the actor reuses the same instance across
/// every message. Used by Bundle D's MSSQL-backed TestKit fixture.
/// </summary>
public AuditLogIngestActor( public AuditLogIngestActor(
IAuditLogRepository repository, IAuditLogRepository repository,
ILogger<AuditLogIngestActor> logger) ILogger<AuditLogIngestActor> logger)
@@ -41,7 +57,27 @@ public class AuditLogIngestActor : ReceiveActor
ArgumentNullException.ThrowIfNull(repository); ArgumentNullException.ThrowIfNull(repository);
ArgumentNullException.ThrowIfNull(logger); ArgumentNullException.ThrowIfNull(logger);
_repository = repository; _injectedRepository = repository;
_logger = logger;
ReceiveAsync<IngestAuditEventsCommand>(OnIngestAsync);
}
/// <summary>
/// Production constructor — resolves <see cref="IAuditLogRepository"/> from
/// a fresh DI scope per message because the repository is a scoped EF Core
/// service registered by <c>AddConfigurationDatabase</c>. The actor itself
/// is a long-lived cluster singleton, so it cannot hold a scope across
/// messages.
/// </summary>
public AuditLogIngestActor(
IServiceProvider serviceProvider,
ILogger<AuditLogIngestActor> logger)
{
ArgumentNullException.ThrowIfNull(serviceProvider);
ArgumentNullException.ThrowIfNull(logger);
_serviceProvider = serviceProvider;
_logger = logger; _logger = logger;
ReceiveAsync<IngestAuditEventsCommand>(OnIngestAsync); ReceiveAsync<IngestAuditEventsCommand>(OnIngestAsync);
@@ -68,27 +104,49 @@ public class AuditLogIngestActor : ReceiveActor
var nowUtc = DateTime.UtcNow; var nowUtc = DateTime.UtcNow;
var accepted = new List<Guid>(cmd.Events.Count); var accepted = new List<Guid>(cmd.Events.Count);
foreach (var evt in cmd.Events) // Resolve the repository for the whole batch — one DbContext per
// message, mirroring NotificationOutboxActor. The injected-repository
// mode (Bundle D tests) skips the scope entirely.
IServiceScope? scope = null;
IAuditLogRepository repository;
if (_injectedRepository is not null)
{ {
try repository = _injectedRepository;
}
else
{
scope = _serviceProvider!.CreateScope();
repository = scope.ServiceProvider.GetRequiredService<IAuditLogRepository>();
}
try
{
foreach (var evt in cmd.Events)
{ {
// Stamp IngestedAtUtc here, not at the site. Bundle A's try
// repository hardening already swallows duplicate-key races, {
// so the same id arriving twice (site retry, reconciliation) // Stamp IngestedAtUtc here, not at the site. Bundle A's
// is a silent no-op. // repository hardening already swallows duplicate-key races,
var ingested = evt with { IngestedAtUtc = nowUtc }; // so the same id arriving twice (site retry, reconciliation)
await _repository.InsertIfNotExistsAsync(ingested).ConfigureAwait(false); // is a silent no-op.
accepted.Add(evt.EventId); var ingested = evt with { IngestedAtUtc = nowUtc };
} await repository.InsertIfNotExistsAsync(ingested).ConfigureAwait(false);
catch (Exception ex) accepted.Add(evt.EventId);
{ }
// Per-row catch — one bad row never sinks the whole batch. catch (Exception ex)
// The row stays Pending at the site; the next drain retries. {
_logger.LogError(ex, // Per-row catch — one bad row never sinks the whole batch.
"Failed to persist audit event {EventId} during batch ingest; row will be retried by the site.", // The row stays Pending at the site; the next drain retries.
evt.EventId); _logger.LogError(ex,
"Failed to persist audit event {EventId} during batch ingest; row will be retried by the site.",
evt.EventId);
}
} }
} }
finally
{
scope?.Dispose();
}
replyTo.Tell(new IngestAuditEventsReply(accepted)); replyTo.Tell(new IngestAuditEventsReply(accepted));
} }

View File

@@ -1,44 +1,106 @@
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using ScadaLink.AuditLog.Configuration; using ScadaLink.AuditLog.Configuration;
using ScadaLink.AuditLog.Site;
using ScadaLink.AuditLog.Site.Telemetry;
using ScadaLink.Commons.Interfaces.Services;
namespace ScadaLink.AuditLog; namespace ScadaLink.AuditLog;
/// <summary> /// <summary>
/// Composition root for the Audit Log (#23) component. M1 registers /// Composition root for the Audit Log (#23) component.
/// <see cref="AuditLogOptions"/> and its validator; later milestones extend
/// this method to wire up writers, telemetry actors, and the central ingest
/// pipeline. Audit Log (#23) sits alongside Notification Outbox (#21) and
/// Site Call Audit (#22).
/// </summary> /// </summary>
/// <remarks>
/// <para>
/// M1 registered <see cref="AuditLogOptions"/> + the validator. M2 Bundle E
/// 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 (#23) sits alongside Notification Outbox (#21) and Site Call
/// Audit (#22). <c>IAuditLogRepository</c> is registered by
/// <c>ScadaLink.ConfigurationDatabase.ServiceCollectionExtensions.AddConfigurationDatabase</c>,
/// so the caller (the Host on the central node) must also call that.
/// </para>
/// </remarks>
public static class ServiceCollectionExtensions public static class ServiceCollectionExtensions
{ {
/// <summary>Configuration section bound to <see cref="AuditLogOptions"/>.</summary> /// <summary>Configuration section bound to <see cref="AuditLogOptions"/>.</summary>
public const string ConfigSectionName = "AuditLog"; 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> /// <summary>
/// Binds <see cref="AuditLogOptions"/> from the /// Registers the Audit Log (#23) component services: options, the site
/// <see cref="ConfigSectionName"/> section of <paramref name="config"/> /// SQLite writer chain (primary + ring fallback + failure-counter sink),
/// and registers <see cref="AuditLogOptionsValidator"/> so a misconfigured /// and the site-→central telemetry collaborators. Idempotent re-registration
/// <c>AuditLog</c> section is rejected with a key-naming message when the /// is not supported; call this exactly once per <see cref="IServiceCollection"/>.
/// options are first resolved (or at startup when consumers wire in
/// <c>ValidateOnStart()</c>). M2+ will register writers, telemetry actors,
/// and the central ingest pipeline here. <c>IAuditLogRepository</c> is
/// registered by
/// <c>ScadaLink.ConfigurationDatabase.ServiceCollectionExtensions.AddConfigurationDatabase</c>,
/// so the caller (the Host on the central node) must also call that.
/// </summary> /// </summary>
public static IServiceCollection AddAuditLog(this IServiceCollection services, IConfiguration config) public static IServiceCollection AddAuditLog(this IServiceCollection services, IConfiguration config)
{ {
ArgumentNullException.ThrowIfNull(services); ArgumentNullException.ThrowIfNull(services);
ArgumentNullException.ThrowIfNull(config); ArgumentNullException.ThrowIfNull(config);
// M1: top-level AuditLogOptions + validator (redaction policy, payload caps, etc.).
services.AddOptions<AuditLogOptions>() services.AddOptions<AuditLogOptions>()
.Bind(config.GetSection(ConfigSectionName)) .Bind(config.GetSection(ConfigSectionName))
.ValidateOnStart(); .ValidateOnStart();
services.AddSingleton<IValidateOptions<AuditLogOptions>, AuditLogOptionsValidator>(); services.AddSingleton<IValidateOptions<AuditLogOptions>, AuditLogOptionsValidator>();
// M2 Bundle E: site writer + telemetry options bindings.
// BindConfiguration is not used because the configuration root supplied
// by the caller may not be the application root — we go through the
// section explicitly so a partial IConfiguration (e.g. a test stub
// anchored on the AuditLog section's parent) still works.
services.AddOptions<SqliteAuditWriterOptions>()
.Bind(config.GetSection(SiteWriterSectionName));
services.AddOptions<SiteAuditTelemetryOptions>()
.Bind(config.GetSection(SiteTelemetrySectionName));
// 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 for M2 (1024).
services.AddSingleton<RingBufferFallback>();
// IAuditWriteFailureCounter: NoOp default. Bundle G overrides this
// binding 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.
services.AddSingleton<IAuditWriter>(sp => new FallbackAuditWriter(
primary: sp.GetRequiredService<SqliteAuditWriter>(),
ring: sp.GetRequiredService<RingBufferFallback>(),
failureCounter: sp.GetRequiredService<IAuditWriteFailureCounter>(),
logger: sp.GetRequiredService<ILogger<FallbackAuditWriter>>()));
// ISiteStreamAuditClient: NoOp default. M6's reconciliation work brings
// the real gRPC-backed implementation (no site→central gRPC channel
// exists today — sites talk to central via Akka ClusterClient only).
// Bundle H's integration test substitutes a stub directly into the
// SiteAuditTelemetryActor's Props.Create call.
services.AddSingleton<ISiteStreamAuditClient, NoOpSiteStreamAuditClient>();
return services; return services;
} }
} }

View File

@@ -0,0 +1,25 @@
namespace ScadaLink.AuditLog.Site;
/// <summary>
/// Default <see cref="IAuditWriteFailureCounter"/> registered by
/// <see cref="ScadaLink.AuditLog.ServiceCollectionExtensions.AddAuditLog"/> on
/// every node. Bundle G replaces this binding with a real counter that bridges
/// into the Site Health Monitoring report payload as
/// <c>SiteAuditWriteFailures</c> — until then,
/// <see cref="FallbackAuditWriter"/> emits to a silent sink rather than NRE-ing
/// on a null collaborator.
/// </summary>
/// <remarks>
/// Audit-write failures must NEVER abort the user-facing action (alog.md §7),
/// so the counter is best-effort by contract. A NoOp default is the correct
/// safe fallback while the health metric is being wired in.
/// </remarks>
public sealed class NoOpAuditWriteFailureCounter : IAuditWriteFailureCounter
{
/// <inheritdoc/>
public void Increment()
{
// Intentionally empty. Bundle G overrides this binding with the real
// counter once Site Health Monitoring is wired.
}
}

View File

@@ -0,0 +1,41 @@
using ScadaLink.Communication.Grpc;
namespace ScadaLink.AuditLog.Site.Telemetry;
/// <summary>
/// Default <see cref="ISiteStreamAuditClient"/> registered by
/// <see cref="ScadaLink.AuditLog.ServiceCollectionExtensions.AddAuditLog"/>.
/// Ships with M2 site-sync-pipeline wiring; the real gRPC-backed
/// implementation is deferred to M6 reconciliation, where a site→central gRPC
/// channel will be introduced (no such channel exists today — sites talk to
/// central exclusively via Akka ClusterClient, while the gRPC SiteStreamService
/// is hosted on the SITE side for central→site streaming).
/// </summary>
/// <remarks>
/// <para>
/// Returns an empty <see cref="IngestAck"/> so the
/// <see cref="SiteAuditTelemetryActor"/> doesn't flip any rows to
/// <c>Forwarded</c> when this NoOp is in effect — Bundle H's integration test
/// substitutes a stub client that routes directly to the central
/// <c>AuditLogIngestActor</c> in-process. Production wiring (M6) will replace
/// this binding with a real client.
/// </para>
/// <para>
/// Audit-write paths are best-effort by contract: a NoOp client keeps the
/// host running cleanly and is consistent with "audit-write failures never
/// abort the user-facing action".
/// </para>
/// </remarks>
public sealed class NoOpSiteStreamAuditClient : ISiteStreamAuditClient
{
private static readonly IngestAck EmptyAck = new();
/// <inheritdoc/>
public Task<IngestAck> IngestAuditEventsAsync(AuditEventBatch batch, CancellationToken ct)
{
ArgumentNullException.ThrowIfNull(batch);
// Empty ack — no EventIds will be flipped to Forwarded, so rows stay
// Pending until M6's real client (or a Bundle H test stub) takes over.
return Task.FromResult(EmptyAck);
}
}

View File

@@ -128,6 +128,13 @@ public class AkkaHostedService : IHostedService
var rolesStr = string.Join(",", roles.Select(QuoteHocon)); var rolesStr = string.Join(",", roles.Select(QuoteHocon));
return $@" return $@"
audit-telemetry-dispatcher {{
type = ForkJoinDispatcher
throughput = 100
dedicated-thread-pool {{
thread-count = 2
}}
}}
akka {{ akka {{
extensions = [ extensions = [
""Akka.Cluster.Tools.PublishSubscribe.DistributedPubSubExtensionProvider, Akka.Cluster.Tools"" ""Akka.Cluster.Tools.PublishSubscribe.DistributedPubSubExtensionProvider, Akka.Cluster.Tools""
@@ -294,6 +301,47 @@ akka {{
commService?.SetNotificationOutbox(outboxProxy); commService?.SetNotificationOutbox(outboxProxy);
_logger.LogInformation("NotificationOutbox singleton created and registered with CentralCommunicationActor"); _logger.LogInformation("NotificationOutbox singleton created and registered with CentralCommunicationActor");
// Audit Log (#23) — central singleton mirrors the Notification Outbox
// pattern. The IngestAuditEvents gRPC handler lives on SiteStreamGrpcServer
// (Communication.Grpc); a central node hosting that server (M6 reconciliation
// path) hands the proxy in via SetAuditIngestActor below. When the gRPC
// server is not registered (current central topology), the host still
// brings the singleton up so a Bundle H in-process test (or a future
// direct caller) can Ask the proxy without further wiring.
// IAuditLogRepository is a SCOPED EF Core service, so the singleton
// actor takes the root IServiceProvider and creates a fresh scope per
// message (mirroring NotificationOutboxActor). Pre-resolving the
// repository here would attempt to take a scoped service from the
// root and fail under DI scope validation.
var auditIngestLogger = _serviceProvider.GetRequiredService<ILoggerFactory>()
.CreateLogger<ScadaLink.AuditLog.Central.AuditLogIngestActor>();
var auditIngestSingletonProps = ClusterSingletonManager.Props(
singletonProps: Props.Create(() => new ScadaLink.AuditLog.Central.AuditLogIngestActor(
_serviceProvider,
auditIngestLogger)),
terminationMessage: PoisonPill.Instance,
settings: ClusterSingletonManagerSettings.Create(_actorSystem!)
.WithSingletonName("audit-log-ingest"));
_actorSystem!.ActorOf(auditIngestSingletonProps, "audit-log-ingest-singleton");
var auditIngestProxyProps = ClusterSingletonProxy.Props(
singletonManagerPath: "/user/audit-log-ingest-singleton",
settings: ClusterSingletonProxySettings.Create(_actorSystem)
.WithSingletonName("audit-log-ingest"));
var auditIngestProxy = _actorSystem.ActorOf(auditIngestProxyProps, "audit-log-ingest-proxy");
// Hand the proxy to the SiteStreamGrpcServer (if registered on this node)
// so the IngestAuditEvents RPC routes incoming site batches to the singleton.
// The gRPC server is currently only registered on Site nodes; on a central
// node this resolves to null and the wiring is a no-op until M6 (which
// brings central-hosted gRPC + a real site→central client).
var grpcServer = _serviceProvider.GetService<ScadaLink.Communication.Grpc.SiteStreamGrpcServer>();
grpcServer?.SetAuditIngestActor(auditIngestProxy);
_logger.LogInformation(
"AuditLogIngestActor singleton created (gRPC server bound: {GrpcBound})",
grpcServer is not null);
_logger.LogInformation("Central actors registered. CentralCommunicationActor created."); _logger.LogInformation("Central actors registered. CentralCommunicationActor created.");
} }
@@ -504,6 +552,41 @@ akka {{
contacts.Count, _nodeOptions.SiteId); contacts.Count, _nodeOptions.SiteId);
} }
// Audit Log (#23) — site-side telemetry actor that drains the SQLite
// Pending queue and pushes to central via IngestAuditEvents. Not a
// cluster singleton: each site is its own cluster, and the actor reads
// node-local SQLite (no replication). The Props are bound to the
// dedicated audit-telemetry-dispatcher (defined in BuildHocon) so a
// batch SQLite read + gRPC push never contend with the default
// dispatcher used by hot-path actors.
//
// Per Bundle E's brief: the SiteAuditTelemetryActor takes its
// collaborators through its constructor, so we resolve them from DI
// and pass them in via Props.Create rather than relying on a future
// FactoryProvider. This also lets the M6 follow-up swap the
// NoOpSiteStreamAuditClient registration for the real gRPC client
// without touching this site wiring.
var siteAuditOptions = _serviceProvider
.GetRequiredService<IOptions<ScadaLink.AuditLog.Site.Telemetry.SiteAuditTelemetryOptions>>();
var siteAuditQueue = _serviceProvider
.GetRequiredService<ScadaLink.AuditLog.Site.Telemetry.ISiteAuditQueue>();
var siteAuditClient = _serviceProvider
.GetRequiredService<ScadaLink.AuditLog.Site.Telemetry.ISiteStreamAuditClient>();
var siteAuditLogger = _serviceProvider.GetRequiredService<ILoggerFactory>()
.CreateLogger<ScadaLink.AuditLog.Site.Telemetry.SiteAuditTelemetryActor>();
var siteAuditTelemetryProps = Props.Create(() =>
new ScadaLink.AuditLog.Site.Telemetry.SiteAuditTelemetryActor(
siteAuditQueue,
siteAuditClient,
siteAuditOptions,
siteAuditLogger))
.WithDispatcher("audit-telemetry-dispatcher");
_actorSystem.ActorOf(siteAuditTelemetryProps, "site-audit-telemetry");
_logger.LogInformation(
"SiteAuditTelemetryActor created (dispatcher=audit-telemetry-dispatcher, client={ClientType})",
siteAuditClient.GetType().Name);
// Gate gRPC subscriptions until the actor system and SiteStreamManager are // Gate gRPC subscriptions until the actor system and SiteStreamManager are
// initialized (REQ-HOST-7). // initialized (REQ-HOST-7).
// //

View File

@@ -1,5 +1,6 @@
using HealthChecks.UI.Client; using HealthChecks.UI.Client;
using Microsoft.AspNetCore.Diagnostics.HealthChecks; using Microsoft.AspNetCore.Diagnostics.HealthChecks;
using ScadaLink.AuditLog;
using ScadaLink.CentralUI; using ScadaLink.CentralUI;
using ScadaLink.ClusterInfrastructure; using ScadaLink.ClusterInfrastructure;
using ScadaLink.Communication; using ScadaLink.Communication;
@@ -77,6 +78,10 @@ try
// AddNotificationService() SMTP machinery above. AddNotificationOutbox binds // AddNotificationService() SMTP machinery above. AddNotificationOutbox binds
// NotificationOutboxOptions via BindConfiguration, so no explicit Configure is needed. // NotificationOutboxOptions via BindConfiguration, so no explicit Configure is needed.
builder.Services.AddNotificationOutbox(); builder.Services.AddNotificationOutbox();
// Audit Log (#23) — central node owns the AuditLogIngestActor singleton +
// IAuditLogRepository. The site writer chain is still registered (lazy
// singletons) but is never resolved on a central node.
builder.Services.AddAuditLog(builder.Configuration);
builder.Services.AddTemplateEngine(); builder.Services.AddTemplateEngine();
builder.Services.AddDeploymentManager(); builder.Services.AddDeploymentManager();
builder.Services.AddSecurity(); builder.Services.AddSecurity();

View File

@@ -38,6 +38,7 @@
<ProjectReference Include="../ScadaLink.ExternalSystemGateway/ScadaLink.ExternalSystemGateway.csproj" /> <ProjectReference Include="../ScadaLink.ExternalSystemGateway/ScadaLink.ExternalSystemGateway.csproj" />
<ProjectReference Include="../ScadaLink.NotificationService/ScadaLink.NotificationService.csproj" /> <ProjectReference Include="../ScadaLink.NotificationService/ScadaLink.NotificationService.csproj" />
<ProjectReference Include="../ScadaLink.NotificationOutbox/ScadaLink.NotificationOutbox.csproj" /> <ProjectReference Include="../ScadaLink.NotificationOutbox/ScadaLink.NotificationOutbox.csproj" />
<ProjectReference Include="../ScadaLink.AuditLog/ScadaLink.AuditLog.csproj" />
<ProjectReference Include="../ScadaLink.CentralUI/ScadaLink.CentralUI.csproj" /> <ProjectReference Include="../ScadaLink.CentralUI/ScadaLink.CentralUI.csproj" />
<ProjectReference Include="../ScadaLink.Security/ScadaLink.Security.csproj" /> <ProjectReference Include="../ScadaLink.Security/ScadaLink.Security.csproj" />
<ProjectReference Include="../ScadaLink.HealthMonitoring/ScadaLink.HealthMonitoring.csproj" /> <ProjectReference Include="../ScadaLink.HealthMonitoring/ScadaLink.HealthMonitoring.csproj" />

View File

@@ -1,3 +1,4 @@
using ScadaLink.AuditLog;
using ScadaLink.ClusterInfrastructure; using ScadaLink.ClusterInfrastructure;
using ScadaLink.Communication; using ScadaLink.Communication;
using ScadaLink.DataConnectionLayer; using ScadaLink.DataConnectionLayer;
@@ -44,6 +45,12 @@ public static class SiteServiceRegistration
services.AddStoreAndForward(); services.AddStoreAndForward();
services.AddSiteEventLogging(); services.AddSiteEventLogging();
// Audit Log (#23) — site-side hot-path writer + telemetry collaborators.
// The SiteAuditTelemetryActor itself is registered by AkkaHostedService
// in the site-role block; this call wires every DI dependency it (and
// ScriptRuntimeContext, when Bundle F lands) reaches for.
services.AddAuditLog(config);
// WP-13: Akka.NET bootstrap via hosted service // WP-13: Akka.NET bootstrap via hosted service
services.AddSingleton<AkkaHostedService>(); services.AddSingleton<AkkaHostedService>();
services.AddHostedService(sp => sp.GetRequiredService<AkkaHostedService>()); services.AddHostedService(sp => sp.GetRequiredService<AkkaHostedService>());

View File

@@ -1,28 +1,42 @@
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using ScadaLink.AuditLog.Configuration; using ScadaLink.AuditLog.Configuration;
using ScadaLink.AuditLog.Site;
using ScadaLink.AuditLog.Site.Telemetry;
using ScadaLink.Commons.Interfaces.Services;
namespace ScadaLink.AuditLog.Tests; namespace ScadaLink.AuditLog.Tests;
/// <summary> /// <summary>
/// Bundle E (M1) smoke tests for the Audit Log (#23) DI scaffold. Verifies /// Bundle E (M2 Task E1) DI surface tests for <c>AddAuditLog</c>. M1 shipped
/// <c>AddAuditLog</c> registers <see cref="AuditLogOptions"/> against the /// the options-only scaffold; M2 extends it with the site writer chain
/// <c>AuditLog</c> configuration section. Bundle E ships only the scaffold; /// (<see cref="SqliteAuditWriter"/> + <see cref="RingBufferFallback"/> +
/// the validator + full options surface land in Task 9. /// <see cref="FallbackAuditWriter"/>) and the telemetry collaborators
/// (<see cref="ISiteAuditQueue"/>, <see cref="ISiteStreamAuditClient"/>,
/// <see cref="IAuditWriteFailureCounter"/>).
/// </summary> /// </summary>
public class AddAuditLogTests public class AddAuditLogTests
{ {
[Fact] private static ServiceProvider BuildProvider(IDictionary<string, string?>? settings = null)
public void AddAuditLog_RegistersAuditLogOptions()
{ {
var config = new ConfigurationBuilder() var config = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>()) .AddInMemoryCollection(settings ?? new Dictionary<string, string?>())
.Build(); .Build();
var services = new ServiceCollection(); var services = new ServiceCollection();
services.AddSingleton<ILoggerFactory, NullLoggerFactory>();
services.AddSingleton(typeof(ILogger<>), typeof(NullLogger<>));
services.AddAuditLog(config); services.AddAuditLog(config);
var provider = services.BuildServiceProvider(); return services.BuildServiceProvider();
}
[Fact]
public void AddAuditLog_RegistersAuditLogOptions()
{
using var provider = BuildProvider();
var opts = provider.GetService<IOptions<AuditLogOptions>>(); var opts = provider.GetService<IOptions<AuditLogOptions>>();
@@ -47,4 +61,130 @@ public class AddAuditLogTests
Assert.Throws<ArgumentNullException>( Assert.Throws<ArgumentNullException>(
() => services.AddAuditLog(null!)); () => services.AddAuditLog(null!));
} }
// -- Bundle E (M2 Task E1) ---------------------------------------------
[Fact]
public void AddAuditLog_Registers_SqliteAuditWriter_Singleton_FromDI()
{
using var provider = BuildProvider(new Dictionary<string, string?>
{
// In-memory database keeps the writer's owned connection portable
// across tests; the per-instance Cache=Shared in the writer's
// default connection string ensures no on-disk file is touched.
["AuditLog:SiteWriter:DatabasePath"] = ":memory:",
});
var writer = provider.GetService<SqliteAuditWriter>();
Assert.NotNull(writer);
// Singleton — same instance on a second resolve.
Assert.Same(writer, provider.GetService<SqliteAuditWriter>());
}
[Fact]
public void AddAuditLog_Registers_IAuditWriter_AsFallbackAuditWriter()
{
using var provider = BuildProvider(new Dictionary<string, string?>
{
["AuditLog:SiteWriter:DatabasePath"] = ":memory:",
});
var writer = provider.GetService<IAuditWriter>();
Assert.NotNull(writer);
Assert.IsType<FallbackAuditWriter>(writer);
}
[Fact]
public void AddAuditLog_Registers_ISiteAuditQueue_AsSameInstance_As_SqliteAuditWriter()
{
// The telemetry actor reads from ISiteAuditQueue while scripts write
// through IAuditWriter → SqliteAuditWriter. Both surfaces MUST resolve
// to the same instance or pending rows will never be visible.
using var provider = BuildProvider(new Dictionary<string, string?>
{
["AuditLog:SiteWriter:DatabasePath"] = ":memory:",
});
var queue = provider.GetService<ISiteAuditQueue>();
var writer = provider.GetService<SqliteAuditWriter>();
Assert.NotNull(queue);
Assert.NotNull(writer);
Assert.Same(writer, queue);
}
[Fact]
public void AddAuditLog_Registers_RingBufferFallback_Singleton()
{
using var provider = BuildProvider(new Dictionary<string, string?>
{
["AuditLog:SiteWriter:DatabasePath"] = ":memory:",
});
var ring = provider.GetService<RingBufferFallback>();
Assert.NotNull(ring);
Assert.Same(ring, provider.GetService<RingBufferFallback>());
}
[Fact]
public void AddAuditLog_Registers_AuditWriteFailureCounter_AsNoOpDefault()
{
using var provider = BuildProvider(new Dictionary<string, string?>
{
["AuditLog:SiteWriter:DatabasePath"] = ":memory:",
});
var counter = provider.GetService<IAuditWriteFailureCounter>();
Assert.NotNull(counter);
Assert.IsType<NoOpAuditWriteFailureCounter>(counter);
}
[Fact]
public void AddAuditLog_Registers_SiteStreamAuditClient_AsNoOpDefault()
{
using var provider = BuildProvider(new Dictionary<string, string?>
{
["AuditLog:SiteWriter:DatabasePath"] = ":memory:",
});
var client = provider.GetService<ISiteStreamAuditClient>();
Assert.NotNull(client);
Assert.IsType<NoOpSiteStreamAuditClient>(client);
}
[Fact]
public void AddAuditLog_Options_Bind_RoundTrip_SqliteWriter()
{
using var provider = BuildProvider(new Dictionary<string, string?>
{
["AuditLog:SiteWriter:DatabasePath"] = "/tmp/test-audit.db",
["AuditLog:SiteWriter:ChannelCapacity"] = "8192",
["AuditLog:SiteWriter:BatchSize"] = "128",
["AuditLog:SiteWriter:FlushIntervalMs"] = "75",
});
var opts = provider.GetRequiredService<IOptions<SqliteAuditWriterOptions>>().Value;
Assert.Equal("/tmp/test-audit.db", opts.DatabasePath);
Assert.Equal(8192, opts.ChannelCapacity);
Assert.Equal(128, opts.BatchSize);
Assert.Equal(75, opts.FlushIntervalMs);
}
[Fact]
public void AddAuditLog_Options_Bind_RoundTrip_SiteTelemetry()
{
using var provider = BuildProvider(new Dictionary<string, string?>
{
["AuditLog:SiteTelemetry:BatchSize"] = "512",
["AuditLog:SiteTelemetry:BusyIntervalSeconds"] = "3",
["AuditLog:SiteTelemetry:IdleIntervalSeconds"] = "60",
});
var opts = provider.GetRequiredService<IOptions<SiteAuditTelemetryOptions>>().Value;
Assert.Equal(512, opts.BatchSize);
Assert.Equal(3, opts.BusyIntervalSeconds);
Assert.Equal(60, opts.IdleIntervalSeconds);
}
} }

View File

@@ -0,0 +1,301 @@
using Akka.Configuration;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Options;
using ScadaLink.AuditLog;
using ScadaLink.AuditLog.Site;
using ScadaLink.AuditLog.Site.Telemetry;
using ScadaLink.ClusterInfrastructure;
using ScadaLink.Commons.Interfaces.Services;
using ScadaLink.ConfigurationDatabase;
using ScadaLink.Host;
using ScadaLink.Host.Actors;
namespace ScadaLink.Host.Tests;
/// <summary>
/// Bundle E (M2 Task E1) — verifies the Audit Log (#23) DI surface is wired
/// into both composition roots and that the HOCON document emitted by
/// <see cref="AkkaHostedService.BuildHocon"/> includes the dedicated
/// <c>audit-telemetry-dispatcher</c> the site telemetry actor binds to.
/// </summary>
/// <remarks>
/// <para>
/// Full cluster bring-up is exercised by the existing
/// <see cref="CompositionRootTests"/> pattern — these tests reuse the same
/// <see cref="AkkaHostedServiceRemover"/> trick to short-circuit
/// <see cref="AkkaHostedService.StartAsync"/> so DI resolution is exercised
/// without the actor system actually being created.
/// </para>
/// </remarks>
public class AkkaHostedServiceAuditWiringHoconTests
{
[Fact]
public void BuildHocon_Emits_AuditTelemetryDispatcher_Block()
{
// Bundle E acceptance: the HOCON document the host parses must declare
// the dedicated dispatcher the SiteAuditTelemetryActor binds to. A
// missing dispatcher block would route the actor to the default
// dispatcher and silently lose the isolation guarantee.
var nodeOptions = new NodeOptions
{
Role = "Site",
NodeHostname = "site-test-1",
RemotingPort = 0,
SiteId = "TestSite",
};
var clusterOptions = new ClusterOptions
{
SeedNodes = new List<string> { "akka.tcp://scadalink@localhost:2551" },
SplitBrainResolverStrategy = "keep-oldest",
MinNrOfMembers = 1,
StableAfter = TimeSpan.FromSeconds(15),
HeartbeatInterval = TimeSpan.FromSeconds(2),
FailureDetectionThreshold = TimeSpan.FromSeconds(10),
};
var hocon = AkkaHostedService.BuildHocon(
nodeOptions,
clusterOptions,
new[] { "Site", "site-TestSite" },
TimeSpan.FromSeconds(5),
TimeSpan.FromSeconds(15));
var config = ConfigurationFactory.ParseString(hocon);
// The dispatcher is declared at the root, so the lookup is by its
// unqualified name. The HOCON parser must accept the block as a
// standalone dispatcher definition the actor system can resolve.
var dispatcherType = config.GetString("audit-telemetry-dispatcher.type");
Assert.Equal("ForkJoinDispatcher", dispatcherType);
var throughput = config.GetInt("audit-telemetry-dispatcher.throughput");
Assert.Equal(100, throughput);
var threadCount = config.GetInt("audit-telemetry-dispatcher.dedicated-thread-pool.thread-count");
Assert.Equal(2, threadCount);
}
}
/// <summary>
/// Verifies Audit Log (#23) services land in the Central composition root.
/// </summary>
public class CentralAuditWiringTests : IDisposable
{
private readonly WebApplicationFactory<Program> _factory;
private readonly string? _previousEnv;
public CentralAuditWiringTests()
{
_previousEnv = Environment.GetEnvironmentVariable("DOTNET_ENVIRONMENT");
Environment.SetEnvironmentVariable("DOTNET_ENVIRONMENT", "Central");
_factory = new WebApplicationFactory<Program>()
.WithWebHostBuilder(builder =>
{
builder.ConfigureAppConfiguration((_, config) =>
{
config.AddInMemoryCollection(new Dictionary<string, string?>
{
["ScadaLink:Node:NodeHostname"] = "localhost",
["ScadaLink:Node:RemotingPort"] = "0",
["ScadaLink:Cluster:SeedNodes:0"] = "akka.tcp://scadalink@localhost:2551",
["ScadaLink:Cluster:SeedNodes:1"] = "akka.tcp://scadalink@localhost:2552",
["ScadaLink:Database:SkipMigrations"] = "true",
["ScadaLink:Security:JwtSigningKey"] = "test-signing-key-must-be-at-least-32-chars-long!",
["ScadaLink:Security:LdapServer"] = "localhost",
["ScadaLink:Security:LdapPort"] = "3893",
["ScadaLink:Security:LdapUseTls"] = "false",
["ScadaLink:Security:AllowInsecureLdap"] = "true",
["ScadaLink:Security:LdapSearchBase"] = "dc=scadalink,dc=local",
["ScadaLink:InboundApi:ApiKeyPepper"] = "test-inbound-api-key-pepper-at-least-32-chars!",
});
});
builder.UseSetting("ScadaLink:Node:Role", "Central");
builder.UseSetting("ScadaLink:Database:SkipMigrations", "true");
builder.ConfigureServices(services =>
{
var descriptorsToRemove = services
.Where(d =>
d.ServiceType == typeof(DbContextOptions<ScadaLinkDbContext>) ||
d.ServiceType == typeof(DbContextOptions) ||
d.ServiceType == typeof(ScadaLinkDbContext) ||
d.ServiceType.FullName?.Contains("EntityFrameworkCore") == true)
.ToList();
foreach (var d in descriptorsToRemove)
services.Remove(d);
services.AddDbContext<ScadaLinkDbContext>(options =>
options.UseInMemoryDatabase($"CentralAuditWiringTests_{Guid.NewGuid()}"));
AkkaHostedServiceRemover.RemoveAkkaHostedServiceOnly(services);
});
});
_ = _factory.Server;
}
public void Dispose()
{
_factory.Dispose();
Environment.SetEnvironmentVariable("DOTNET_ENVIRONMENT", _previousEnv);
}
[Fact]
public void Central_Resolves_IAuditWriter_AsFallbackAuditWriter()
{
// Central nodes still register the writer chain because AddAuditLog is
// shared between roles — the registrations are lazy singletons and the
// writer is never resolved on a central node in production. Asserting
// it resolves here confirms the chain is intact and ready for the
// future case where a central-only actor needs to emit audit events.
var writer = _factory.Services.GetService<IAuditWriter>();
Assert.NotNull(writer);
Assert.IsType<FallbackAuditWriter>(writer);
}
[Fact]
public void Central_Resolves_AuditLogOptions()
{
var opts = _factory.Services.GetService<IOptions<ScadaLink.AuditLog.Configuration.AuditLogOptions>>();
Assert.NotNull(opts);
Assert.NotNull(opts!.Value);
}
[Fact]
public void Central_Resolves_SqliteAuditWriterOptions()
{
var opts = _factory.Services.GetService<IOptions<SqliteAuditWriterOptions>>();
Assert.NotNull(opts);
Assert.NotNull(opts!.Value);
}
[Fact]
public void Central_Resolves_SiteAuditTelemetryOptions()
{
var opts = _factory.Services.GetService<IOptions<SiteAuditTelemetryOptions>>();
Assert.NotNull(opts);
Assert.NotNull(opts!.Value);
}
[Fact]
public void Central_Resolves_ISiteStreamAuditClient_AsNoOpDefault()
{
var client = _factory.Services.GetService<ISiteStreamAuditClient>();
Assert.NotNull(client);
Assert.IsType<NoOpSiteStreamAuditClient>(client);
}
}
/// <summary>
/// Verifies Audit Log (#23) services land in the Site composition root.
/// </summary>
public class SiteAuditWiringTests : IDisposable
{
private readonly WebApplication _host;
private readonly string _tempDbPath;
public SiteAuditWiringTests()
{
_tempDbPath = Path.Combine(Path.GetTempPath(), $"scadalink_audit_wiring_{Guid.NewGuid()}.db");
var builder = WebApplication.CreateBuilder();
builder.Configuration.Sources.Clear();
builder.Configuration.AddInMemoryCollection(new Dictionary<string, string?>
{
["ScadaLink:Node:Role"] = "Site",
["ScadaLink:Node:NodeHostname"] = "test-site",
["ScadaLink:Node:SiteId"] = "TestSite",
["ScadaLink:Node:RemotingPort"] = "0",
["ScadaLink:Node:GrpcPort"] = "0",
["ScadaLink:Database:SiteDbPath"] = _tempDbPath,
["ScadaLink:Cluster:SeedNodes:0"] = "akka.tcp://scadalink@localhost:2551",
["ScadaLink:Cluster:SeedNodes:1"] = "akka.tcp://scadalink@localhost:2552",
// SqliteAuditWriter would attempt to open a SQLite file when first
// resolved; point it at an in-memory connection so the test doesn't
// pollute the working directory.
["AuditLog:SiteWriter:DatabasePath"] = ":memory:",
});
builder.Services.AddGrpc();
builder.Services.AddSingleton<ScadaLink.Communication.Grpc.SiteStreamGrpcServer>();
SiteServiceRegistration.Configure(builder.Services, builder.Configuration);
AkkaHostedServiceRemover.RemoveAkkaHostedServiceOnly(builder.Services);
_host = builder.Build();
}
public void Dispose()
{
(_host as IDisposable)?.Dispose();
try { File.Delete(_tempDbPath); } catch { /* best effort */ }
}
[Fact]
public void Site_Resolves_IAuditWriter_AsFallbackAuditWriter()
{
var writer = _host.Services.GetService<IAuditWriter>();
Assert.NotNull(writer);
Assert.IsType<FallbackAuditWriter>(writer);
}
[Fact]
public void Site_Resolves_SqliteAuditWriter_AsSingleton()
{
var a = _host.Services.GetService<SqliteAuditWriter>();
var b = _host.Services.GetService<SqliteAuditWriter>();
Assert.NotNull(a);
Assert.NotNull(b);
Assert.Same(a, b);
}
[Fact]
public void Site_ISiteAuditQueue_AndSqliteAuditWriter_AreSameInstance()
{
// The telemetry actor reads from ISiteAuditQueue while ScriptRuntimeContext
// writes through IAuditWriter → SqliteAuditWriter. If these don't resolve
// to the same instance, pending rows are invisible to the actor.
var queue = _host.Services.GetService<ISiteAuditQueue>();
var writer = _host.Services.GetService<SqliteAuditWriter>();
Assert.NotNull(queue);
Assert.NotNull(writer);
Assert.Same(writer, queue);
}
[Fact]
public void Site_Resolves_RingBufferFallback()
{
var ring = _host.Services.GetService<RingBufferFallback>();
Assert.NotNull(ring);
}
[Fact]
public void Site_Resolves_IAuditWriteFailureCounter_AsNoOpDefault()
{
var counter = _host.Services.GetService<IAuditWriteFailureCounter>();
Assert.NotNull(counter);
Assert.IsType<NoOpAuditWriteFailureCounter>(counter);
}
[Fact]
public void Site_Resolves_ISiteStreamAuditClient_AsNoOpDefault()
{
var client = _host.Services.GetService<ISiteStreamAuditClient>();
Assert.NotNull(client);
Assert.IsType<NoOpSiteStreamAuditClient>(client);
}
[Fact]
public void Site_Resolves_SiteAuditTelemetryOptions_WithDefaults()
{
var opts = _host.Services.GetService<IOptions<SiteAuditTelemetryOptions>>();
Assert.NotNull(opts);
Assert.Equal(256, opts!.Value.BatchSize);
Assert.Equal(5, opts.Value.BusyIntervalSeconds);
Assert.Equal(30, opts.Value.IdleIntervalSeconds);
}
}