Phase 1 WP-11–22: Host infrastructure, Blazor Server UI, and integration tests

Host infrastructure (WP-11–17):
- StartupValidator with 19 validation rules
- /health/ready endpoint with DB + Akka health checks
- Akka.NET bootstrap via AkkaHostedService (HOCON config, cluster, remoting, SBR)
- Serilog with SiteId/NodeHostname/NodeRole enrichment
- DeadLetterMonitorActor with count tracking
- CoordinatedShutdown wiring (no Environment.Exit)
- Windows Service support (UseWindowsService)

Central UI (WP-18–21):
- Blazor Server shell with Bootstrap 5, role-aware NavMenu
- Login/logout flow (LDAP auth → JWT → HTTP-only cookie)
- CookieAuthenticationStateProvider with idle timeout
- LDAP group mapping CRUD page (Admin role)
- Route guards with Authorize attributes per role
- SignalR reconnection overlay for failover

Integration tests (WP-22):
- Startup validation, auth flow, audit transactions, readiness gating
186 tests pass (1 skipped: LDAP integration), zero warnings.
This commit is contained in:
Joseph Doherty
2026-03-16 19:50:59 -04:00
parent cafb7d2006
commit d38356efdb
47 changed files with 2436 additions and 71 deletions

View File

@@ -0,0 +1,106 @@
using Akka.Actor;
using Akka.Configuration;
using Microsoft.Extensions.Options;
using ScadaLink.ClusterInfrastructure;
using ScadaLink.Host.Actors;
namespace ScadaLink.Host.Actors;
/// <summary>
/// Hosted service that manages the Akka.NET actor system lifecycle.
/// Creates the actor system on start, registers actors, and triggers
/// CoordinatedShutdown on stop.
/// </summary>
public class AkkaHostedService : IHostedService
{
private readonly IServiceProvider _serviceProvider;
private readonly NodeOptions _nodeOptions;
private readonly ClusterOptions _clusterOptions;
private readonly ILogger<AkkaHostedService> _logger;
private ActorSystem? _actorSystem;
public AkkaHostedService(
IServiceProvider serviceProvider,
IOptions<NodeOptions> nodeOptions,
IOptions<ClusterOptions> clusterOptions,
ILogger<AkkaHostedService> logger)
{
_serviceProvider = serviceProvider;
_nodeOptions = nodeOptions.Value;
_clusterOptions = clusterOptions.Value;
_logger = logger;
}
/// <summary>
/// Gets the actor system once started. Null before StartAsync completes.
/// </summary>
public ActorSystem? ActorSystem => _actorSystem;
public Task StartAsync(CancellationToken cancellationToken)
{
var seedNodesStr = string.Join(",",
_clusterOptions.SeedNodes.Select(s => $"\"{s}\""));
var hocon = $@"
akka {{
actor {{
provider = cluster
}}
remote {{
dot-netty.tcp {{
hostname = ""{_nodeOptions.NodeHostname}""
port = {_nodeOptions.RemotingPort}
}}
}}
cluster {{
seed-nodes = [{seedNodesStr}]
roles = [""{_nodeOptions.Role}""]
min-nr-of-members = {_clusterOptions.MinNrOfMembers}
split-brain-resolver {{
active-strategy = {_clusterOptions.SplitBrainResolverStrategy}
stable-after = {_clusterOptions.StableAfter.TotalSeconds:F0}s
keep-oldest {{
down-if-alone = on
}}
}}
failure-detector {{
heartbeat-interval = {_clusterOptions.HeartbeatInterval.TotalSeconds:F0}s
acceptable-heartbeat-pause = {_clusterOptions.FailureDetectionThreshold.TotalSeconds:F0}s
}}
run-coordinated-shutdown-when-down = on
}}
coordinated-shutdown {{
run-by-clr-shutdown-hook = on
}}
}}";
var config = ConfigurationFactory.ParseString(hocon);
_actorSystem = ActorSystem.Create("scadalink", config);
_logger.LogInformation(
"Akka.NET actor system 'scadalink' started. Role={Role}, Hostname={Hostname}, Port={Port}",
_nodeOptions.Role,
_nodeOptions.NodeHostname,
_nodeOptions.RemotingPort);
// Register the dead letter monitor actor
var loggerFactory = _serviceProvider.GetRequiredService<ILoggerFactory>();
var dlmLogger = loggerFactory.CreateLogger<DeadLetterMonitorActor>();
_actorSystem.ActorOf(
Props.Create(() => new DeadLetterMonitorActor(dlmLogger)),
"dead-letter-monitor");
return Task.CompletedTask;
}
public async Task StopAsync(CancellationToken cancellationToken)
{
if (_actorSystem != null)
{
_logger.LogInformation("Shutting down Akka.NET actor system via CoordinatedShutdown...");
var shutdown = Akka.Actor.CoordinatedShutdown.Get(_actorSystem);
await shutdown.Run(Akka.Actor.CoordinatedShutdown.ClrExitReason.Instance);
_logger.LogInformation("Akka.NET actor system shutdown complete.");
}
}
}

View File

@@ -0,0 +1,53 @@
using Akka.Actor;
using Akka.Event;
using Microsoft.Extensions.Logging;
namespace ScadaLink.Host.Actors;
/// <summary>
/// Subscribes to Akka.NET dead letter events, logs them, and tracks count
/// for health monitoring integration.
/// </summary>
public class DeadLetterMonitorActor : ReceiveActor
{
private long _deadLetterCount;
public DeadLetterMonitorActor(ILogger<DeadLetterMonitorActor> logger)
{
Receive<DeadLetter>(dl =>
{
_deadLetterCount++;
logger.LogWarning(
"Dead letter: {MessageType} from {Sender} to {Recipient}",
dl.Message.GetType().Name,
dl.Sender,
dl.Recipient);
});
Receive<GetDeadLetterCount>(_ => Sender.Tell(new DeadLetterCountResponse(_deadLetterCount)));
}
protected override void PreStart()
{
Context.System.EventStream.Subscribe(Self, typeof(DeadLetter));
}
protected override void PostStop()
{
Context.System.EventStream.Unsubscribe(Self, typeof(DeadLetter));
}
}
/// <summary>
/// Message to request the current dead letter count.
/// </summary>
public sealed class GetDeadLetterCount
{
public static readonly GetDeadLetterCount Instance = new();
private GetDeadLetterCount() { }
}
/// <summary>
/// Response containing the current dead letter count.
/// </summary>
public sealed record DeadLetterCountResponse(long Count);

View File

@@ -0,0 +1,19 @@
using Microsoft.Extensions.Diagnostics.HealthChecks;
namespace ScadaLink.Host.Health;
/// <summary>
/// Health check that verifies Akka.NET cluster membership.
/// Initially returns healthy; will be refined when Akka cluster integration is complete.
/// </summary>
public class AkkaClusterHealthCheck : IHealthCheck
{
public Task<HealthCheckResult> CheckHealthAsync(
HealthCheckContext context,
CancellationToken cancellationToken = default)
{
// TODO: Query Akka Cluster.Get(system).State to verify this node is Up.
// For now, return healthy as Akka cluster wiring is being established.
return Task.FromResult(HealthCheckResult.Healthy("Akka cluster health check placeholder."));
}
}

View File

@@ -0,0 +1,34 @@
using Microsoft.Extensions.Diagnostics.HealthChecks;
using ScadaLink.ConfigurationDatabase;
namespace ScadaLink.Host.Health;
/// <summary>
/// Health check that verifies database connectivity for Central nodes.
/// </summary>
public class DatabaseHealthCheck : IHealthCheck
{
private readonly ScadaLinkDbContext _dbContext;
public DatabaseHealthCheck(ScadaLinkDbContext dbContext)
{
_dbContext = dbContext;
}
public async Task<HealthCheckResult> CheckHealthAsync(
HealthCheckContext context,
CancellationToken cancellationToken = default)
{
try
{
var canConnect = await _dbContext.Database.CanConnectAsync(cancellationToken);
return canConnect
? HealthCheckResult.Healthy("Database connection is available.")
: HealthCheckResult.Unhealthy("Database connection failed.");
}
catch (Exception ex)
{
return HealthCheckResult.Unhealthy("Database connection failed.", ex);
}
}
}

View File

@@ -1,3 +1,5 @@
using HealthChecks.UI.Client;
using Microsoft.AspNetCore.Diagnostics.HealthChecks;
using ScadaLink.CentralUI;
using ScadaLink.ClusterInfrastructure;
using ScadaLink.Communication;
@@ -7,6 +9,8 @@ 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.NotificationService;
using ScadaLink.Security;
@@ -14,6 +18,7 @@ using ScadaLink.SiteEventLogging;
using ScadaLink.SiteRuntime;
using ScadaLink.StoreAndForward;
using ScadaLink.TemplateEngine;
using Serilog;
var configuration = new ConfigurationBuilder()
.AddJsonFile("appsettings.json", optional: false)
@@ -22,86 +27,148 @@ var configuration = new ConfigurationBuilder()
.AddCommandLine(args)
.Build();
var role = configuration["ScadaLink:Node:Role"]
?? throw new InvalidOperationException("ScadaLink:Node:Role is required");
// WP-11: Full startup validation — fail fast before any DI or actor system setup
StartupValidator.Validate(configuration);
if (role.Equals("Central", StringComparison.OrdinalIgnoreCase))
// 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
Log.Logger = new LoggerConfiguration()
.ReadFrom.Configuration(configuration)
.Enrich.WithProperty("SiteId", siteId)
.Enrich.WithProperty("NodeHostname", nodeHostname)
.Enrich.WithProperty("NodeRole", nodeRole)
.WriteTo.Console(outputTemplate:
"[{Timestamp:HH:mm:ss} {Level:u3}] [{NodeRole}/{NodeHostname}] {Message:lj}{NewLine}{Exception}")
.WriteTo.File("logs/scadalink-.log", rollingInterval: Serilog.RollingInterval.Day)
.CreateLogger();
try
{
var builder = WebApplication.CreateBuilder(args);
builder.Configuration.AddConfiguration(configuration);
Log.Information("Starting ScadaLink host as {Role} on {Hostname}", nodeRole, nodeHostname);
// Shared components
builder.Services.AddClusterInfrastructure();
builder.Services.AddCommunication();
builder.Services.AddHealthMonitoring();
builder.Services.AddExternalSystemGateway();
builder.Services.AddNotificationService();
// Central-only components
builder.Services.AddTemplateEngine();
builder.Services.AddDeploymentManager();
builder.Services.AddSecurity();
builder.Services.AddCentralUI();
builder.Services.AddInboundAPI();
var configDbConnectionString = configuration["ScadaLink:Database:ConfigurationDb"]
?? throw new InvalidOperationException("ScadaLink:Database:ConfigurationDb connection string is required for Central role.");
builder.Services.AddConfigurationDatabase(configDbConnectionString);
// Options binding
BindSharedOptions(builder.Services, builder.Configuration);
builder.Services.Configure<SecurityOptions>(builder.Configuration.GetSection("ScadaLink:Security"));
builder.Services.Configure<InboundApiOptions>(builder.Configuration.GetSection("ScadaLink:InboundApi"));
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))
if (nodeRole.Equals("Central", StringComparison.OrdinalIgnoreCase))
{
var isDevelopment = app.Environment.IsDevelopment();
using (var scope = app.Services.CreateScope())
{
var dbContext = scope.ServiceProvider.GetRequiredService<ScadaLinkDbContext>();
await MigrationHelper.ApplyOrValidateMigrationsAsync(dbContext, isDevelopment);
}
}
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();
app.MapCentralUI();
app.MapInboundAPI();
await app.RunAsync();
}
else if (role.Equals("Site", StringComparison.OrdinalIgnoreCase))
{
var builder = Microsoft.Extensions.Hosting.Host.CreateDefaultBuilder(args);
builder.ConfigureAppConfiguration(config => config.AddConfiguration(configuration));
builder.ConfigureServices((context, services) =>
{
// Shared components
services.AddClusterInfrastructure();
services.AddCommunication();
services.AddHealthMonitoring();
services.AddExternalSystemGateway();
services.AddNotificationService();
builder.Services.AddClusterInfrastructure();
builder.Services.AddCommunication();
builder.Services.AddHealthMonitoring();
builder.Services.AddExternalSystemGateway();
builder.Services.AddNotificationService();
// Site-only components
services.AddSiteRuntime();
services.AddDataConnectionLayer();
services.AddStoreAndForward();
services.AddSiteEventLogging();
// Central-only components
builder.Services.AddTemplateEngine();
builder.Services.AddDeploymentManager();
builder.Services.AddSecurity();
builder.Services.AddCentralUI();
builder.Services.AddInboundAPI();
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");
// WP-13: Akka.NET bootstrap via hosted service
builder.Services.AddSingleton<AkkaHostedService>();
builder.Services.AddHostedService(sp => sp.GetRequiredService<AkkaHostedService>());
// Options binding
BindSharedOptions(services, context.Configuration);
services.Configure<DataConnectionOptions>(context.Configuration.GetSection("ScadaLink:DataConnection"));
services.Configure<StoreAndForwardOptions>(context.Configuration.GetSection("ScadaLink:StoreAndForward"));
services.Configure<SiteEventLogOptions>(context.Configuration.GetSection("ScadaLink:SiteEventLog"));
});
BindSharedOptions(builder.Services, builder.Configuration);
builder.Services.Configure<SecurityOptions>(builder.Configuration.GetSection("ScadaLink:Security"));
builder.Services.Configure<InboundApiOptions>(builder.Configuration.GetSection("ScadaLink:InboundApi"));
var host = builder.Build();
await host.RunAsync();
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();
using (var scope = app.Services.CreateScope())
{
var dbContext = scope.ServiceProvider.GetRequiredService<ScadaLinkDbContext>();
await MigrationHelper.ApplyOrValidateMigrationsAsync(dbContext, isDevelopment);
}
}
// WP-12: Map readiness endpoint — returns 503 until all checks pass, 200 when ready
app.MapHealthChecks("/health/ready", new HealthCheckOptions
{
ResponseWriter = UIResponseWriter.WriteHealthCheckUIResponse
});
app.MapCentralUI();
app.MapInboundAPI();
await app.RunAsync();
}
else if (nodeRole.Equals("Site", StringComparison.OrdinalIgnoreCase))
{
var builder = Microsoft.Extensions.Hosting.Host.CreateDefaultBuilder(args);
builder.ConfigureAppConfiguration(config => config.AddConfiguration(configuration));
// WP-14: Serilog
builder.UseSerilog();
// WP-17: Windows Service support (no-op when not running as a Windows Service)
builder.UseWindowsService();
builder.ConfigureServices((context, services) =>
{
// Shared components
services.AddClusterInfrastructure();
services.AddCommunication();
services.AddHealthMonitoring();
services.AddExternalSystemGateway();
services.AddNotificationService();
// Site-only components
services.AddSiteRuntime();
services.AddDataConnectionLayer();
services.AddStoreAndForward();
services.AddSiteEventLogging();
// WP-13: Akka.NET bootstrap via hosted service
services.AddSingleton<AkkaHostedService>();
services.AddHostedService(sp => sp.GetRequiredService<AkkaHostedService>());
// Options binding
BindSharedOptions(services, context.Configuration);
services.Configure<DataConnectionOptions>(context.Configuration.GetSection("ScadaLink:DataConnection"));
services.Configure<StoreAndForwardOptions>(context.Configuration.GetSection("ScadaLink:StoreAndForward"));
services.Configure<SiteEventLogOptions>(context.Configuration.GetSection("ScadaLink:SiteEventLog"));
});
var host = builder.Build();
await host.RunAsync();
}
else
{
throw new InvalidOperationException($"Unknown role: {nodeRole}. Must be 'Central' or 'Site'.");
}
}
else
catch (Exception ex)
{
throw new InvalidOperationException($"Unknown role: {role}. Must be 'Central' or 'Site'.");
Log.Fatal(ex, "ScadaLink host terminated unexpectedly");
throw;
}
finally
{
await Log.CloseAndFlushAsync();
}
static void BindSharedOptions(IServiceCollection services, IConfiguration config)

View File

@@ -8,10 +8,18 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Akka.Cluster.Hosting" Version="1.5.62" />
<PackageReference Include="Akka.Hosting" Version="1.5.62" />
<PackageReference Include="Akka.Remote.Hosting" Version="1.5.62" />
<PackageReference Include="AspNetCore.HealthChecks.UI.Client" Version="9.0.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.5">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Microsoft.Extensions.Hosting.WindowsServices" Version="10.0.5" />
<PackageReference Include="Serilog.AspNetCore" Version="10.0.0" />
<PackageReference Include="Serilog.Sinks.Console" Version="6.1.1" />
<PackageReference Include="Serilog.Sinks.File" Version="7.0.0" />
</ItemGroup>
<ItemGroup>

View File

@@ -0,0 +1,58 @@
namespace ScadaLink.Host;
/// <summary>
/// Validates required configuration before Akka.NET actor system creation.
/// Runs early in startup to fail fast with clear error messages.
/// </summary>
public static class StartupValidator
{
public static void Validate(IConfiguration configuration)
{
var errors = new List<string>();
var nodeSection = configuration.GetSection("ScadaLink:Node");
var role = nodeSection["Role"];
if (string.IsNullOrEmpty(role) || (role != "Central" && role != "Site"))
errors.Add("ScadaLink:Node:Role must be 'Central' or 'Site'");
if (string.IsNullOrEmpty(nodeSection["NodeHostname"]))
errors.Add("ScadaLink:Node:NodeHostname is required");
var portStr = nodeSection["RemotingPort"];
if (!int.TryParse(portStr, out var port) || port < 1 || port > 65535)
errors.Add("ScadaLink:Node:RemotingPort must be 1-65535");
if (role == "Site" && string.IsNullOrEmpty(nodeSection["SiteId"]))
errors.Add("ScadaLink:Node:SiteId is required for Site nodes");
if (role == "Central")
{
var dbSection = configuration.GetSection("ScadaLink:Database");
if (string.IsNullOrEmpty(dbSection["ConfigurationDb"]))
errors.Add("ScadaLink:Database:ConfigurationDb connection string required for Central");
if (string.IsNullOrEmpty(dbSection["MachineDataDb"]))
errors.Add("ScadaLink:Database:MachineDataDb connection string required for Central");
var secSection = configuration.GetSection("ScadaLink:Security");
if (string.IsNullOrEmpty(secSection["LdapServer"]))
errors.Add("ScadaLink:Security:LdapServer required for Central");
if (string.IsNullOrEmpty(secSection["JwtSigningKey"]))
errors.Add("ScadaLink:Security:JwtSigningKey required for Central");
}
if (role == "Site")
{
var dbSection = configuration.GetSection("ScadaLink:Database");
if (string.IsNullOrEmpty(dbSection["SiteDbPath"]))
errors.Add("ScadaLink:Database:SiteDbPath required for Site nodes");
}
var seedNodes = configuration.GetSection("ScadaLink:Cluster:SeedNodes").Get<List<string>>();
if (seedNodes == null || seedNodes.Count < 2)
errors.Add("ScadaLink:Cluster:SeedNodes must have at least 2 entries");
if (errors.Count > 0)
throw new InvalidOperationException(
$"Configuration validation failed:\n{string.Join("\n", errors.Select(e => $" - {e}"))}");
}
}