e14433cd64
Wire the M6 KPI History recorder into the central composition path: - Program.cs: call services.AddKpiHistory(configuration) on the central-only branch alongside AddNotificationOutbox/AddAuditLog/AddSiteCallAudit. - AkkaHostedService.cs: register KpiHistoryRecorderActor as a central, non-role-scoped ClusterSingletonManager + ClusterSingletonProxy + a PhaseClusterLeave CoordinatedShutdown graceful-stop drain (singleton name 'kpi-history-recorder'), copied/adapted from the audit-log-purge block. - appsettings.Central.json (Host + docker + docker-env2 central nodes): add a ScadaBridge:KpiHistory section (SampleInterval 00:01:00, RetentionDays 90, PurgeInterval 1.00:00:00, DefaultMaxSeriesPoints 200). KPI history is observability/best-effort and MUST NOT gate readiness: the recorder is deliberately NOT added to RequiredSingletonsHealthCheck or any other readiness gate.
481 lines
27 KiB
C#
481 lines
27 KiB
C#
using ZB.MOM.WW.Auth.ApiKeys.DependencyInjection;
|
|
using ZB.MOM.WW.Auth.AspNetCore;
|
|
using ZB.MOM.WW.Health;
|
|
using ZB.MOM.WW.Health.Akka;
|
|
using ZB.MOM.WW.Health.EntityFrameworkCore;
|
|
using ZB.MOM.WW.ScadaBridge.AuditLog;
|
|
using ZB.MOM.WW.ScadaBridge.CentralUI;
|
|
using ZB.MOM.WW.ScadaBridge.ClusterInfrastructure;
|
|
using ZB.MOM.WW.ScadaBridge.Communication;
|
|
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase;
|
|
using ZB.MOM.WW.ScadaBridge.DeploymentManager;
|
|
using ZB.MOM.WW.ScadaBridge.ExternalSystemGateway;
|
|
using ZB.MOM.WW.ScadaBridge.HealthMonitoring;
|
|
using ZB.MOM.WW.ScadaBridge.Host;
|
|
using ZB.MOM.WW.ScadaBridge.Host.Actors;
|
|
using ZB.MOM.WW.ScadaBridge.Host.Health;
|
|
using ZB.MOM.WW.ScadaBridge.InboundAPI;
|
|
using ZB.MOM.WW.ScadaBridge.InboundAPI.Middleware;
|
|
using ZB.MOM.WW.ScadaBridge.KpiHistory;
|
|
using ZB.MOM.WW.ScadaBridge.ManagementService;
|
|
using ZB.MOM.WW.ScadaBridge.NotificationOutbox;
|
|
using ZB.MOM.WW.ScadaBridge.NotificationService;
|
|
using ZB.MOM.WW.ScadaBridge.Security;
|
|
using ZB.MOM.WW.ScadaBridge.SiteCallAudit;
|
|
using ZB.MOM.WW.ScadaBridge.TemplateEngine;
|
|
using ZB.MOM.WW.ScadaBridge.Transport;
|
|
using ZB.MOM.WW.Telemetry;
|
|
using Serilog;
|
|
|
|
// SCADABRIDGE_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 scadabridgeConfig = Environment.GetEnvironmentVariable("SCADABRIDGE_CONFIG")
|
|
?? Environment.GetEnvironmentVariable("DOTNET_ENVIRONMENT")
|
|
?? "Production";
|
|
|
|
var configuration = new ConfigurationBuilder()
|
|
.AddJsonFile("appsettings.json", optional: false)
|
|
.AddJsonFile($"appsettings.{scadabridgeConfig}.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["ScadaBridge:Node:Role"]!;
|
|
var nodeHostname = configuration["ScadaBridge:Node:NodeHostname"] ?? "unknown";
|
|
var siteId = configuration["ScadaBridge:Node:SiteId"] ?? "central";
|
|
|
|
// WP-14: Serilog structured logging.
|
|
// Host-011: minimum level is driven by ScadaBridge: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 = ZB.MOM.WW.ScadaBridge.Host.LoggerConfigurationFactory
|
|
.Build(configuration, nodeRole, siteId, nodeHostname)
|
|
.CreateLogger();
|
|
|
|
try
|
|
{
|
|
Log.Information("Starting ScadaBridge 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();
|
|
// Transport (#24) — central-only bundle export/import pipeline. Binds
|
|
// TransportOptions from ScadaBridge:Transport via BindConfiguration; no
|
|
// explicit Configure needed.
|
|
builder.Services.AddTransport();
|
|
// 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);
|
|
// #23 M6-T5 Bundle D — central-only hosted service that rolls
|
|
// pf_AuditLog_Month forward monthly. Depends on IPartitionMaintenance
|
|
// (registered below by AddConfigurationDatabase).
|
|
builder.Services.AddAuditLogCentralMaintenance(builder.Configuration);
|
|
// #23 M6 Bundle B/C — central-only registration backing the two
|
|
// maintenance singletons started in AkkaHostedService: the production
|
|
// ISiteEnumerator + IPullAuditEventsClient (gRPC) used by the
|
|
// SiteAuditReconciliationActor, plus the AuditLogPurgeOptions /
|
|
// SiteAuditReconciliationOptions bindings consumed by both singletons.
|
|
// Central-only by design (it dials sites), kept out of AddAuditLog.
|
|
builder.Services.AddAuditLogCentralReconciliationClient(builder.Configuration);
|
|
// Site Call Audit (#22) — central node owns the SiteCallAuditActor
|
|
// singleton (M3 Bundle F). The extension itself currently registers
|
|
// nothing — actor Props are constructed inline in AkkaHostedService —
|
|
// but the call is here for symmetry with the other audit composition
|
|
// roots so future per-actor DI lands without touching Program.cs.
|
|
builder.Services.AddSiteCallAudit();
|
|
// KPI History (#26, M6) — central-only. Binds KpiHistoryOptions from
|
|
// ScadaBridge:KpiHistory and registers the validated options consumed by
|
|
// the KpiHistoryRecorderActor cluster singleton (started in
|
|
// AkkaHostedService). Observability/best-effort: NOT readiness-gated.
|
|
builder.Services.AddKpiHistory(builder.Configuration);
|
|
builder.Services.AddTemplateEngine();
|
|
builder.Services.AddDeploymentManager();
|
|
// Host is the composition root and owns config-coupled wiring: register the
|
|
// shared LDAP auth (binds LdapOptions + IValidateOptions<LdapOptions> with
|
|
// ValidateOnStart + ILdapAuthService singleton) here, then AddSecurity() for the
|
|
// config-free remainder. AddSecurity is a component library and takes no
|
|
// IConfiguration (Options pattern only). Behaviour-preserving: identical
|
|
// registrations to the previous AddSecurity(builder.Configuration) call.
|
|
builder.Services.AddZbLdapAuth(
|
|
builder.Configuration,
|
|
ZB.MOM.WW.ScadaBridge.Security.ServiceCollectionExtensions.LdapSectionPath);
|
|
// Dev disable-login flag (config-coupled, so read + bound here at the composition root,
|
|
// mirroring AddZbLdapAuth). Default false. See AuthDisableLoginOptions / disable-login design doc.
|
|
builder.Services.AddOptions<ZB.MOM.WW.ScadaBridge.Security.Auth.AuthDisableLoginOptions>()
|
|
.Bind(builder.Configuration.GetSection(
|
|
ZB.MOM.WW.ScadaBridge.Security.Auth.AuthDisableLoginOptions.SectionName));
|
|
var disableLogin = builder.Configuration
|
|
.GetSection(ZB.MOM.WW.ScadaBridge.Security.Auth.AuthDisableLoginOptions.SectionName)
|
|
.GetValue<bool>(nameof(ZB.MOM.WW.ScadaBridge.Security.Auth.AuthDisableLoginOptions.DisableLogin));
|
|
builder.Services.AddSecurity(disableLogin);
|
|
builder.Services.AddCentralUI();
|
|
builder.Services.AddInboundAPI();
|
|
|
|
// Inbound-API auth re-arch: the shared ZB.MOM.WW.Auth.ApiKeys verifier +
|
|
// SQLite store + startup migration are now the SOLE inbound-API auth path.
|
|
// The POST /api/{methodName} endpoint authenticates Bearer tokens
|
|
// (sbk_<keyId>_<secret>) and authorizes by scope == method name through this
|
|
// verifier. The legacy peppered-HMAC X-API-Key path — the SQL Server ApiKey
|
|
// entity, ApiKeyValidator, and IApiKeyHasher — was retired in re-arch C5; the
|
|
// ScadaBridge:InboundApi:ApiKeyPepper config key is now consumed only as the
|
|
// library verifier's pepper secret (PepperSecretName below).
|
|
//
|
|
// ApiKeyOptions is an init-only record, so the contract-mandated values
|
|
// are injected as in-memory configuration UNDER the bound section path
|
|
// (ScadaBridge:InboundApi:ApiKeyStore) rather than mutated post-bind:
|
|
// - TokenPrefix = "sbk" (the inbound token prefix)
|
|
// - PepperSecretName points at the EXISTING inbound-API pepper config key
|
|
// (reused so one secret backs both the legacy and new path during the
|
|
// additive window)
|
|
// - RunMigrationsOnStartup = true (hosted service creates the schema)
|
|
// - SqlitePath defaults under the content root's data/ directory, but any
|
|
// value already supplied via appsettings/env wins (AddInMemoryCollection
|
|
// is registered last, but only fills keys the operator did not set
|
|
// because we read the existing value first).
|
|
const string apiKeyStoreSection = "ScadaBridge:InboundApi:ApiKeyStore";
|
|
var configuredSqlitePath = builder.Configuration[$"{apiKeyStoreSection}:SqlitePath"];
|
|
var apiKeyStoreDefaults = new Dictionary<string, string?>
|
|
{
|
|
[$"{apiKeyStoreSection}:TokenPrefix"] = "sbk",
|
|
[$"{apiKeyStoreSection}:PepperSecretName"] = "ScadaBridge:InboundApi:ApiKeyPepper",
|
|
[$"{apiKeyStoreSection}:RunMigrationsOnStartup"] = "true",
|
|
[$"{apiKeyStoreSection}:SqlitePath"] = string.IsNullOrWhiteSpace(configuredSqlitePath)
|
|
? Path.Combine(builder.Environment.ContentRootPath, "data", "inbound-api-keys.sqlite")
|
|
: configuredSqlitePath,
|
|
};
|
|
builder.Configuration.AddInMemoryCollection(apiKeyStoreDefaults);
|
|
|
|
builder.Services.AddZbApiKeyAuth(builder.Configuration, apiKeyStoreSection);
|
|
|
|
// Inbound-API key re-arch (C1), additive: expose the library admin facade
|
|
// (ApiKeyAdminCommands) and the app-side management seam (IInboundApiKeyAdmin)
|
|
// in the SAME container as AddZbApiKeyAuth, so CLI + CentralUI later create /
|
|
// list / enable / disable / delete inbound keys and edit their method-scopes
|
|
// through one shared path. AddZbApiKeyAuth registers the stores/pepper/migrator
|
|
// but NOT ApiKeyAdminCommands itself, so it is composed here. CentralUI resolves
|
|
// from this same provider (it is registered via AddCentralUI() above), so the
|
|
// seam is reachable from both the ManagementActor and CentralUI pages — exactly
|
|
// as IInboundApiRepository already is.
|
|
builder.Services.AddSingleton(sp => new ZB.MOM.WW.Auth.ApiKeys.Admin.ApiKeyAdminCommands(
|
|
sp.GetRequiredService<Microsoft.Extensions.Options.IOptions<ZB.MOM.WW.Auth.Abstractions.ApiKeys.ApiKeyOptions>>().Value,
|
|
sp.GetRequiredService<ZB.MOM.WW.Auth.Abstractions.ApiKeys.IApiKeyAdminStore>(),
|
|
sp.GetRequiredService<ZB.MOM.WW.Auth.Abstractions.ApiKeys.IApiKeyAuditStore>(),
|
|
sp.GetRequiredService<ZB.MOM.WW.Auth.ApiKeys.IApiKeyPepperProvider>(),
|
|
sp.GetRequiredService<ZB.MOM.WW.Auth.ApiKeys.Sqlite.SqliteAuthStoreMigrator>(),
|
|
TimeProvider.System));
|
|
builder.Services.AddSingleton<
|
|
ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Security.IInboundApiKeyAdmin,
|
|
LibraryInboundApiKeyAdmin>();
|
|
|
|
builder.Services.AddManagementService();
|
|
|
|
var configDbConnectionString = configuration["ScadaBridge:Database:ConfigurationDb"]
|
|
?? throw new InvalidOperationException("ScadaBridge:Database:ConfigurationDb connection string is required for Central role.");
|
|
builder.Services.AddConfigurationDatabase(configDbConnectionString);
|
|
|
|
// WP-12: Health checks for readiness gating — shared ZB.MOM.WW.Health probes.
|
|
// Check names and the ready/active tier split are preserved: database + akka-cluster
|
|
// carry the Ready tag (/health/ready), active-node carries the Active tag (/health/active).
|
|
// The Akka checks resolve ActorSystem from DI via the transient bridge registered below;
|
|
// the DatabaseHealthCheck<TContext> resolves a scoped ScadaBridgeDbContext (no factory).
|
|
builder.Services.AddHealthChecks()
|
|
.AddTypeActivatedCheck<DatabaseHealthCheck<ScadaBridgeDbContext>>(
|
|
"database",
|
|
failureStatus: null,
|
|
tags: new[] { ZbHealthTags.Ready })
|
|
.AddTypeActivatedCheck<AkkaClusterHealthCheck>(
|
|
"akka-cluster",
|
|
failureStatus: null,
|
|
tags: new[] { ZbHealthTags.Ready },
|
|
args: AkkaClusterStatusPolicy.Default)
|
|
// M2.14 (#28): readiness ALSO reflects "required cluster singletons running"
|
|
// (REQ-HOST-4a). Probes each central singleton's local ClusterSingletonProxy
|
|
// with a bounded Identify and degrades to Unhealthy if any required singleton
|
|
// is unreachable. Registered inside the Central-role branch (this is it) so the
|
|
// check is naturally role-scoped — site nodes never run it. It resolves
|
|
// ActorSystem from DI per probe, like the akka-cluster check above, and is
|
|
// leadership-agnostic so a ready standby still reports ready (the proxy reaches
|
|
// the singleton from either node).
|
|
.AddTypeActivatedCheck<RequiredSingletonsHealthCheck>(
|
|
"required-singletons",
|
|
failureStatus: null,
|
|
tags: new[] { ZbHealthTags.Ready })
|
|
.AddTypeActivatedCheck<ActiveNodeHealthCheck>(
|
|
"active-node",
|
|
failureStatus: null,
|
|
tags: new[] { ZbHealthTags.Active });
|
|
|
|
// WP-13: Akka.NET bootstrap via hosted service
|
|
builder.Services.AddSingleton<AkkaHostedService>();
|
|
builder.Services.AddHostedService(sp => sp.GetRequiredService<AkkaHostedService>());
|
|
|
|
// HOST-021: bridge the AkkaHostedService-owned ActorSystem to DI as a SINGLETON via
|
|
// GetOrCreateActorSystem(). The shared ZB.MOM.WW.Health Akka checks resolve ActorSystem
|
|
// from DI, per probe, inside a child scope. ActorSystem is IDisposable, so a TRANSIENT
|
|
// (or scoped) bridge is captured-and-disposed by each probe's scope — disposing the live
|
|
// system mid-flight (CoordinatedShutdown/ActorSystemTerminateReason) and wedging the
|
|
// central report pages at the 30s Ask timeout. A singleton is resolved from the root and
|
|
// never disposed by a child scope; routing through GetOrCreateActorSystem (instead of a
|
|
// plain singleton factory over .ActorSystem) means the first resolve CREATES the system
|
|
// rather than caching a null if a probe wins the startup race.
|
|
builder.Services.AddSingleton<Akka.Actor.ActorSystem>(sp =>
|
|
sp.GetRequiredService<AkkaHostedService>().GetOrCreateActorSystem());
|
|
|
|
// InboundAPI-022: register the production IActiveNodeGate implementation so
|
|
// standby-node gating is actually enforced (the InboundApiEndpointFilter
|
|
// consults IActiveNodeGate and defaults to "allow" when none is registered,
|
|
// which leaves the design's "central cluster only (active node)" guarantee
|
|
// unenforced in deployed binaries). The gate is backed by the same Akka
|
|
// cluster-leadership check as ActiveNodeHealthCheck above, so the inbound
|
|
// API and the /health/active endpoint Traefik routes against agree on
|
|
// which node is active.
|
|
builder.Services.AddSingleton<ZB.MOM.WW.ScadaBridge.InboundAPI.IActiveNodeGate, ActiveNodeGate>();
|
|
|
|
// 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("ScadaBridge:Security"));
|
|
builder.Services.Configure<InboundApiOptions>(builder.Configuration.GetSection("ScadaBridge:InboundApi"));
|
|
builder.Services.Configure<DeploymentManagerOptions>(
|
|
builder.Configuration.GetSection(ZB.MOM.WW.ScadaBridge.DeploymentManager.ServiceCollectionExtensions.OptionsSection));
|
|
|
|
var app = builder.Build();
|
|
|
|
// Apply or validate database migrations (skip when running in test harness)
|
|
if (!string.Equals(configuration["ScadaBridge: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.
|
|
// Host-019: thread the host's ApplicationStopping token into both the
|
|
// migration call itself and the inter-attempt Task.Delay so a SIGTERM
|
|
// during the bounded-retry window (~2 min worst-case) tears down
|
|
// cleanly instead of being ignored until the loop exhausts.
|
|
await StartupRetry.ExecuteWithRetryAsync(
|
|
"database-migration",
|
|
async ct =>
|
|
{
|
|
using var scope = app.Services.CreateScope();
|
|
var dbContext = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
|
await MigrationHelper.ApplyOrValidateMigrationsAsync(dbContext, isDevelopment, migrationLogger, ct);
|
|
},
|
|
maxAttempts: 8,
|
|
initialDelay: TimeSpan.FromSeconds(2),
|
|
migrationLogger,
|
|
isTransient: StartupRetry.IsTransientDatabaseFault,
|
|
cancellationToken: app.Lifetime.ApplicationStopping);
|
|
}
|
|
|
|
// Middleware pipeline
|
|
app.UseWebSockets();
|
|
app.UseRouting();
|
|
app.UseAuthentication();
|
|
app.UseAuthorization();
|
|
app.UseAntiforgery();
|
|
|
|
// Audit Log #23 (M4 Bundle D, T8): emit one InboundRequest/InboundAuthFailure
|
|
// audit row per call into the inbound API. Placed AFTER UseAuthentication/
|
|
// UseAuthorization so any HttpContext.User the framework populates is in
|
|
// place, and scoped to the /api/ prefix so it never observes the Central UI,
|
|
// Management API, SignalR hubs, or health endpoints. The endpoint handler
|
|
// is responsible for stashing the resolved API key name on
|
|
// HttpContext.Items (see AuditWriteMiddleware.AuditActorItemKey) AFTER its
|
|
// in-handler API key validation succeeds.
|
|
// InboundAPI-025: scope the audit middleware to the inbound API method
|
|
// route (/api/{methodName}) and explicitly exclude the management/audit
|
|
// sub-trees that share the /api prefix. Without these exclusions the
|
|
// middleware would emit a spurious ApiInbound audit row for every
|
|
// /api/audit/query and /api/audit/export call (and would treat audit-log
|
|
// reads as inbound script invocations — recursive write-on-read). The
|
|
// POST-only filter rules out the GET routes on /api/audit, /api/centralui,
|
|
// /api/script-analysis even if a future route is added under those
|
|
// prefixes with the same verb; the explicit prefix excludes still belt-
|
|
// and-brace POST-y additions there.
|
|
app.UseWhen(
|
|
ctx => ctx.Request.Path.StartsWithSegments("/api")
|
|
&& !ctx.Request.Path.StartsWithSegments("/api/audit")
|
|
&& !ctx.Request.Path.StartsWithSegments("/api/centralui")
|
|
&& !ctx.Request.Path.StartsWithSegments("/api/script-analysis")
|
|
&& !ctx.Request.Path.StartsWithSegments("/api/management")
|
|
&& HttpMethods.IsPost(ctx.Request.Method),
|
|
branch => branch.UseAuditWriteMiddleware());
|
|
|
|
// WP-12: Map the canonical three-tier health endpoints in one call:
|
|
// /health/ready — Ready-tagged checks (database + akka-cluster). REQ-HOST-4a defines
|
|
// readiness as cluster membership + DB connectivity, explicitly NOT
|
|
// cluster leadership, so the leader-only active-node check is excluded
|
|
// (a fully operational standby central node still reports ready).
|
|
// /health/active — Active-tagged check (active-node); returns 200 only on the cluster
|
|
// leader; used by Traefik for routing.
|
|
// /healthz — bare process liveness; runs no checks (always 200 while the process
|
|
// is up). New tier added by adopting the shared library.
|
|
// All three are anonymous and use the canonical ZbHealthWriter JSON output.
|
|
app.MapZbHealth();
|
|
|
|
// Observability — mount the always-on Prometheus /metrics scrape endpoint.
|
|
// AddZbTelemetry (in SiteServiceRegistration.BindSharedOptions) wires the OTel
|
|
// Resource + standard instrumentation + Prometheus exporter; this exposes them.
|
|
// Requires endpoint routing (app.UseRouting() above).
|
|
app.MapZbMetrics();
|
|
|
|
app.MapStaticAssets();
|
|
app.MapCentralUI<ZB.MOM.WW.ScadaBridge.Host.Components.App>();
|
|
app.MapInboundAPI();
|
|
app.MapManagementAPI();
|
|
// Audit Log #23 (M8): CLI-facing /api/audit/{query,export} routes. Same
|
|
// Basic-Auth + LDAP mechanism as /management; gated on the OperationalAudit
|
|
// / AuditExport role sets.
|
|
app.MapAuditAPI();
|
|
app.MapHub<ZB.MOM.WW.ScadaBridge.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<ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories.IInboundApiRepository>();
|
|
var executor = app.Services.GetRequiredService<ZB.MOM.WW.ScadaBridge.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>("ScadaBridge:Node:GrpcPort", 8083);
|
|
|
|
// Read MetricsPort from config (NodeOptions already has default 8084).
|
|
// Separate HTTP/1.1 listener so a standard HTTP/1.1 Prometheus scraper can
|
|
// reach /metrics; the gRPC port stays HTTP/2-only below. The default is
|
|
// 8084 — distinct from RemotingPort (8082, Akka) and GrpcPort (8083) so the
|
|
// metrics listener never collides with the Akka remoting port on site nodes.
|
|
var metricsPort = configuration.GetValue<int>("ScadaBridge:Node:MetricsPort", 8084);
|
|
|
|
// 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;
|
|
});
|
|
|
|
// Dedicated HTTP/1.1 (and HTTP/2) listener for the Prometheus /metrics
|
|
// scrape endpoint, reachable by an HTTP/1.1 scraper.
|
|
options.ListenAnyIP(metricsPort, listenOptions =>
|
|
{
|
|
listenOptions.Protocols = Microsoft.AspNetCore.Server.Kestrel.Core.HttpProtocols.Http1AndHttp2;
|
|
});
|
|
});
|
|
|
|
// gRPC server registration
|
|
builder.Services.AddGrpc();
|
|
builder.Services.AddSingleton<ZB.MOM.WW.ScadaBridge.Communication.Grpc.SiteStreamGrpcServer>();
|
|
|
|
// Existing site service registrations
|
|
SiteServiceRegistration.Configure(builder.Services, builder.Configuration);
|
|
|
|
var app = builder.Build();
|
|
|
|
// Endpoint routing middleware. The gRPC service mapping below and the
|
|
// /metrics scrape endpoint both run on endpoint routing, so UseRouting()
|
|
// must be present before the Map* calls on the site role.
|
|
app.UseRouting();
|
|
|
|
// Observability — mount the always-on Prometheus /metrics scrape endpoint.
|
|
// AddZbTelemetry (in SiteServiceRegistration.Configure → BindSharedOptions)
|
|
// wires the OTel Resource + standard instrumentation + Prometheus exporter;
|
|
// this exposes them on the site node too.
|
|
app.MapZbMetrics();
|
|
|
|
// Map gRPC service — resolves the singleton SiteStreamGrpcServer from DI
|
|
app.MapGrpcService<ZB.MOM.WW.ScadaBridge.Communication.Grpc.SiteStreamGrpcServer>();
|
|
|
|
// Host-017 / REQ-HOST-7: site-shutdown ordering. ApplicationStopping
|
|
// fires BEFORE IHostedService.StopAsync runs, so the gRPC server
|
|
// refuses new streams (Unavailable) and cancels every active stream
|
|
// here — clients observe a clean Cancelled and reconnect — and only
|
|
// THEN does AkkaHostedService run CoordinatedShutdown and tear down
|
|
// actors. Without this hand-off, in-flight streams go silent and only
|
|
// time out via gRPC keepalive (~25 s), violating the documented
|
|
// four-step sequence.
|
|
var siteLifetime = app.Services.GetRequiredService<Microsoft.Extensions.Hosting.IHostApplicationLifetime>();
|
|
var siteGrpcServer = app.Services.GetRequiredService<ZB.MOM.WW.ScadaBridge.Communication.Grpc.SiteStreamGrpcServer>();
|
|
siteLifetime.ApplicationStopping.Register(() => siteGrpcServer.CancelAllStreams());
|
|
|
|
await app.RunAsync();
|
|
}
|
|
else
|
|
{
|
|
throw new InvalidOperationException($"Unknown role: {nodeRole}. Must be 'Central' or 'Site'.");
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Log.Fatal(ex, "ScadaBridge 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 { }
|