The debug view polled every 2s by re-subscribing for full snapshots. Now a persistent DebugStreamBridgeActor on central subscribes once and receives incremental Akka stream events from the site, forwarding them to the Blazor component via callbacks and to the CLI via a new SignalR hub at /hubs/debug-stream. Adds `debug stream` CLI command with auto-reconnect.
195 lines
7.7 KiB
C#
195 lines
7.7 KiB
C#
using HealthChecks.UI.Client;
|
|
using Microsoft.AspNetCore.Diagnostics.HealthChecks;
|
|
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.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
|
|
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
|
|
{
|
|
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
|
|
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>());
|
|
|
|
// 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"));
|
|
|
|
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);
|
|
using (var scope = app.Services.CreateScope())
|
|
{
|
|
var dbContext = scope.ServiceProvider.GetRequiredService<ScadaLinkDbContext>();
|
|
await MigrationHelper.ApplyOrValidateMigrationsAsync(dbContext, isDevelopment);
|
|
}
|
|
}
|
|
|
|
// Middleware pipeline
|
|
app.UseStaticFiles();
|
|
app.UseWebSockets();
|
|
app.UseRouting();
|
|
app.UseAuthentication();
|
|
app.UseAuthorization();
|
|
app.UseAntiforgery();
|
|
|
|
// WP-12: Map readiness endpoint — returns 503 until all checks pass, 200 when ready
|
|
app.MapHealthChecks("/health/ready", new HealthCheckOptions
|
|
{
|
|
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 = 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) =>
|
|
{
|
|
SiteServiceRegistration.Configure(services, context.Configuration);
|
|
});
|
|
|
|
var host = builder.Build();
|
|
await host.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 { }
|