Files
scadalink-design/src/ScadaLink.Host/Program.cs
Joseph Doherty 9bf1497f03 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).
2026-05-20 13:04:05 -04:00

252 lines
11 KiB
C#

using HealthChecks.UI.Client;
using Microsoft.AspNetCore.Diagnostics.HealthChecks;
using ScadaLink.AuditLog;
using ScadaLink.CentralUI;
using ScadaLink.ClusterInfrastructure;
using ScadaLink.Communication;
using ScadaLink.ConfigurationDatabase;
using ScadaLink.DeploymentManager;
using ScadaLink.ExternalSystemGateway;
using ScadaLink.HealthMonitoring;
using ScadaLink.Host;
using ScadaLink.Host.Actors;
using ScadaLink.Host.Health;
using ScadaLink.InboundAPI;
using ScadaLink.ManagementService;
using ScadaLink.NotificationOutbox;
using ScadaLink.NotificationService;
using ScadaLink.Security;
using ScadaLink.TemplateEngine;
using Serilog;
// SCADALINK_CONFIG determines which role-specific config to load (Central or Site)
// DOTNET_ENVIRONMENT/ASPNETCORE_ENVIRONMENT stay as "Development" for dev tooling (static assets, EF migrations, etc.)
var scadalinkConfig = Environment.GetEnvironmentVariable("SCADALINK_CONFIG")
?? Environment.GetEnvironmentVariable("DOTNET_ENVIRONMENT")
?? "Production";
var configuration = new ConfigurationBuilder()
.AddJsonFile("appsettings.json", optional: false)
.AddJsonFile($"appsettings.{scadalinkConfig}.json", optional: true)
.AddEnvironmentVariables()
.AddCommandLine(args)
.Build();
// WP-11: Full startup validation — fail fast before any DI or actor system setup
StartupValidator.Validate(configuration);
// Read node options for Serilog enrichment
var nodeRole = configuration["ScadaLink:Node:Role"]!;
var nodeHostname = configuration["ScadaLink:Node:NodeHostname"] ?? "unknown";
var siteId = configuration["ScadaLink:Node:SiteId"] ?? "central";
// WP-14: Serilog structured logging.
// Host-011: minimum level is driven by ScadaLink:Logging:MinimumLevel (LoggingOptions).
// Host-014: console and file sinks are defined in the `Serilog` configuration
// section (appsettings.json) and applied via ReadFrom.Configuration inside the
// factory — the sink set, output template, file path and rolling interval are all
// configuration-driven per REQ-HOST-8, not hard-coded here.
Log.Logger = ScadaLink.Host.LoggerConfigurationFactory
.Build(configuration, nodeRole, siteId, nodeHostname)
.CreateLogger();
try
{
Log.Information("Starting ScadaLink host as {Role} on {Hostname}", nodeRole, nodeHostname);
if (nodeRole.Equals("Central", StringComparison.OrdinalIgnoreCase))
{
var builder = WebApplication.CreateBuilder(args);
builder.Configuration.AddConfiguration(configuration);
// WP-14: Serilog
builder.Host.UseSerilog();
// WP-17: Windows Service support (no-op when not running as a Windows Service)
builder.Host.UseWindowsService();
// Shared components
builder.Services.AddClusterInfrastructure();
builder.Services.AddCommunication();
builder.Services.AddHealthMonitoring();
builder.Services.AddCentralHealthAggregation();
builder.Services.AddExternalSystemGateway();
builder.Services.AddNotificationService();
// Central-only components
// Notification Outbox: central owns SMTP delivery; the Email adapter reuses the
// AddNotificationService() SMTP machinery above. AddNotificationOutbox binds
// NotificationOutboxOptions via BindConfiguration, so no explicit Configure is needed.
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.AddDeploymentManager();
builder.Services.AddSecurity();
builder.Services.AddCentralUI();
builder.Services.AddInboundAPI();
builder.Services.AddManagementService();
var configDbConnectionString = configuration["ScadaLink:Database:ConfigurationDb"]
?? throw new InvalidOperationException("ScadaLink:Database:ConfigurationDb connection string is required for Central role.");
builder.Services.AddConfigurationDatabase(configDbConnectionString);
// WP-12: Health checks for readiness gating
builder.Services.AddHealthChecks()
.AddCheck<DatabaseHealthCheck>("database")
.AddCheck<AkkaClusterHealthCheck>("akka-cluster")
.AddCheck<ActiveNodeHealthCheck>("active-node");
// WP-13: Akka.NET bootstrap via hosted service
builder.Services.AddSingleton<AkkaHostedService>();
builder.Services.AddHostedService(sp => sp.GetRequiredService<AkkaHostedService>());
// Cluster node status provider scoped to the Central role — feeds the
// CentralHealthReportLoop so the central cluster appears on /monitoring/health.
builder.Services.AddSingleton<IClusterNodeProvider>(sp =>
{
var akkaService = sp.GetRequiredService<AkkaHostedService>();
return new AkkaClusterNodeProvider(akkaService, "Central");
});
// Options binding
SiteServiceRegistration.BindSharedOptions(builder.Services, builder.Configuration);
builder.Services.Configure<SecurityOptions>(builder.Configuration.GetSection("ScadaLink:Security"));
builder.Services.Configure<InboundApiOptions>(builder.Configuration.GetSection("ScadaLink:InboundApi"));
builder.Services.Configure<DeploymentManagerOptions>(
builder.Configuration.GetSection(ScadaLink.DeploymentManager.ServiceCollectionExtensions.OptionsSection));
var app = builder.Build();
// Apply or validate database migrations (skip when running in test harness)
if (!string.Equals(configuration["ScadaLink:Database:SkipMigrations"], "true", StringComparison.OrdinalIgnoreCase))
{
var isDevelopment = app.Environment.IsDevelopment()
|| string.Equals(Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT"), "Development", StringComparison.OrdinalIgnoreCase);
var migrationLogger = app.Services
.GetRequiredService<ILoggerFactory>()
.CreateLogger(typeof(MigrationHelper).FullName!);
// Host-010: tolerate a database that is briefly unreachable at boot
// (e.g. app and DB containers starting together) with a bounded
// exponential backoff before failing fatally.
// Host-015: only connection-class (transient) faults are retried — a
// schema-version mismatch is permanent and must fail fast on attempt 1.
await StartupRetry.ExecuteWithRetryAsync(
"database-migration",
async () =>
{
using var scope = app.Services.CreateScope();
var dbContext = scope.ServiceProvider.GetRequiredService<ScadaLinkDbContext>();
await MigrationHelper.ApplyOrValidateMigrationsAsync(dbContext, isDevelopment, migrationLogger);
},
maxAttempts: 8,
initialDelay: TimeSpan.FromSeconds(2),
migrationLogger,
isTransient: StartupRetry.IsTransientDatabaseFault);
}
// Middleware pipeline
app.UseWebSockets();
app.UseRouting();
app.UseAuthentication();
app.UseAuthorization();
app.UseAntiforgery();
// WP-12: Map readiness endpoint — returns 503 until ready, 200 when ready.
// REQ-HOST-4a defines readiness as cluster membership + DB connectivity,
// explicitly NOT cluster leadership. The leader-only "active-node" check is
// excluded here so a fully operational standby central node reports ready;
// leadership is reported separately on /health/active.
app.MapHealthChecks("/health/ready", new HealthCheckOptions
{
Predicate = check => check.Name != "active-node",
ResponseWriter = UIResponseWriter.WriteHealthCheckUIResponse
});
// Active node endpoint — returns 200 only on the cluster leader; used by Traefik for routing
app.MapHealthChecks("/health/active", new HealthCheckOptions
{
Predicate = check => check.Name == "active-node",
ResponseWriter = UIResponseWriter.WriteHealthCheckUIResponse
});
app.MapStaticAssets();
app.MapCentralUI<ScadaLink.Host.Components.App>();
app.MapInboundAPI();
app.MapManagementAPI();
app.MapHub<ScadaLink.ManagementService.DebugStreamHub>("/hubs/debug-stream");
// Compile and register all Inbound API method scripts at startup
using (var scope = app.Services.CreateScope())
{
var apiRepo = scope.ServiceProvider.GetRequiredService<ScadaLink.Commons.Interfaces.Repositories.IInboundApiRepository>();
var executor = app.Services.GetRequiredService<ScadaLink.InboundAPI.InboundScriptExecutor>();
var methods = await apiRepo.GetAllApiMethodsAsync();
foreach (var method in methods)
{
executor.CompileAndRegister(method);
}
}
await app.RunAsync();
}
else if (nodeRole.Equals("Site", StringComparison.OrdinalIgnoreCase))
{
var builder = WebApplication.CreateBuilder(args);
builder.Configuration.AddConfiguration(configuration);
// WP-14: Serilog
builder.Host.UseSerilog();
// WP-17: Windows Service support (no-op when not running as a Windows Service)
builder.Host.UseWindowsService();
// Read GrpcPort from config (NodeOptions already has default 8083)
var grpcPort = configuration.GetValue<int>("ScadaLink:Node:GrpcPort", 8083);
// Configure Kestrel for HTTP/2 only on the gRPC port
builder.WebHost.ConfigureKestrel(options =>
{
options.ListenAnyIP(grpcPort, listenOptions =>
{
listenOptions.Protocols = Microsoft.AspNetCore.Server.Kestrel.Core.HttpProtocols.Http2;
});
});
// gRPC server registration
builder.Services.AddGrpc();
builder.Services.AddSingleton<ScadaLink.Communication.Grpc.SiteStreamGrpcServer>();
// Existing site service registrations
SiteServiceRegistration.Configure(builder.Services, builder.Configuration);
var app = builder.Build();
// Map gRPC service — resolves the singleton SiteStreamGrpcServer from DI
app.MapGrpcService<ScadaLink.Communication.Grpc.SiteStreamGrpcServer>();
await app.RunAsync();
}
else
{
throw new InvalidOperationException($"Unknown role: {nodeRole}. Must be 'Central' or 'Site'.");
}
}
catch (Exception ex)
{
Log.Fatal(ex, "ScadaLink host terminated unexpectedly");
throw;
}
finally
{
await Log.CloseAndFlushAsync();
}
/// <summary>
/// Exposes the auto-generated Program class for test infrastructure (e.g. WebApplicationFactory).
/// </summary>
public partial class Program { }