using Akka.Actor; using Akka.Hosting; using Microsoft.Extensions.DependencyInjection.Extensions; using Serilog; using Serilog.Events; using ZB.MOM.WW.OtOpcUa.AdminUI; using ZB.MOM.WW.OtOpcUa.AdminUI.Clients; using ZB.MOM.WW.OtOpcUa.AdminUI.Components; using ZB.MOM.WW.OtOpcUa.AdminUI.Hubs; using ZB.MOM.WW.OtOpcUa.AdminUI.ScriptAnalysis; using ZB.MOM.WW.OtOpcUa.Cluster; using ZB.MOM.WW.OtOpcUa.Configuration; using ZB.MOM.WW.OtOpcUa.ControlPlane; using ZB.MOM.WW.OtOpcUa.Commons.OpcUa; using ZB.MOM.WW.OtOpcUa.Commons.Engines; using ZB.MOM.WW.OtOpcUa.Core.Scripting; using ZB.MOM.WW.OtOpcUa.Host; using ZB.MOM.WW.OtOpcUa.Host.Configuration; using ZB.MOM.WW.OtOpcUa.Host.Drivers; using ZB.MOM.WW.OtOpcUa.Host.Engines; using ZB.MOM.WW.OtOpcUa.Host.Health; using ZB.MOM.WW.OtOpcUa.Host.Logging; using ZB.MOM.WW.OtOpcUa.Host.Observability; using ZB.MOM.WW.OtOpcUa.Host.OpcUa; using ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway; using ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Recorder; using ZB.MOM.WW.OtOpcUa.Core.Abstractions.Historian; using ZB.MOM.WW.OtOpcUa.OpcUaServer; using ZB.MOM.WW.OtOpcUa.Runtime.Historian; using ZB.MOM.WW.OtOpcUa.Runtime.Scripting; using ZB.MOM.WW.OtOpcUa.OpcUaServer.Security; using ZB.MOM.WW.OtOpcUa.Runtime; using ZB.MOM.WW.Auth.Abstractions.Roles; using ZB.MOM.WW.OtOpcUa.Security; using ZB.MOM.WW.OtOpcUa.Security.Endpoints; using ZB.MOM.WW.OtOpcUa.Security.Ldap; using ZB.MOM.WW.Configuration; using ZB.MOM.WW.Telemetry.Serilog; using ZB.MOM.WW.OtOpcUa.AdminUI.Api; // Roles drive the entire conditional wiring below — see ZB.MOM.WW.OtOpcUa.Cluster.RoleParser. var roles = RoleParser.Parse(Environment.GetEnvironmentVariable("OTOPCUA_ROLES")); var hasAdmin = roles.Contains("admin"); var hasDriver = roles.Contains("driver"); var builder = WebApplication.CreateBuilder(args); // Razor class library static assets (_content//...) only auto-enable in // the Development environment. Opt in explicitly so the AdminUI's CSS/JS works // regardless of ASPNETCORE_ENVIRONMENT. builder.WebHost.UseStaticWebAssets(); // Windows Service support: when the EXE is started by Service Control Manager (sc.exe), // the host needs to call SetServiceStatus to keep the SCM happy. UseWindowsService() // installs the WindowsServiceLifetime IFF WindowsServiceHelpers.IsWindowsService() is // true at runtime — so it's safely a no-op when running as a console app or on Linux. builder.Host.UseWindowsService(options => options.ServiceName = "OtOpcUaHost"); // Per-role appsettings overlay: appsettings.{role}.json (single role) or appsettings.admin-driver.json // (both). Optional — base appsettings.json carries enough to boot if these don't exist. var roleSuffix = roles.Length == 0 ? null : string.Join('-', roles.OrderBy(r => r, StringComparer.Ordinal)); if (roleSuffix is not null) { builder.Configuration.AddJsonFile($"appsettings.{roleSuffix}.json", optional: true, reloadOnChange: true); // The overlay above is appended AFTER WebApplicationBuilder's default sources, so without this it // would outrank environment variables and command-line args — i.e. a baked role file could not be // overridden by a deployment env var (e.g. the dev compose's Security__Ldap__DevStubMode=true was // silently beaten by appsettings.admin.json's DevStubMode:false). Re-append env vars then args last // so deployment overrides keep top precedence; the overlay still wins over base appsettings.json. builder.Configuration.AddEnvironmentVariables(); builder.Configuration.AddCommandLine(args); } // Serilog — shared ZB.MOM.WW.Telemetry bootstrap. Sinks (Console + rolling daily file) // now live in appsettings.json (ReadFrom.Configuration); AddZbSerilog layers in the // shared NodeHostname / TraceContext / Redaction enrichers and trace correlation. builder.AddZbSerilog(o => o.ServiceName = "otopcua"); // Windows-service registration is handled at install time by scripts/install/Install-Services.ps1 // (Task 62) rather than in-process, so the binary stays cross-platform-compilable. // Shared services — always registered regardless of role. ConfigDb is required for everything. builder.Services.AddOtOpcUaConfigDb(builder.Configuration); builder.Services.AddOtOpcUaCluster(builder.Configuration); // Validate LdapOptions unconditionally so ANY role node (admin-only, driver-only, or fused) // fails fast at startup on a misconfigured or insecure-transport LDAP section. // The validator's own guard (if !Enabled || DevStubMode) keeps it a safe no-op on disabled/dev // configs. Previously this was inside the hasDriver block, which left admin-only nodes without // the startup check (AddOtOpcUaAuth binds LdapOptions but does not attach a validator). builder.Services.AddValidatedOptions(builder.Configuration, LdapOptions.SectionName); if (hasDriver) { builder.Services.AddOtOpcUaRuntime(); // Config-gated durable alarm-historian sink. When the AlarmHistorian section is enabled this // overrides the NullAlarmHistorianSink default from AddOtOpcUaRuntime (last registration wins) // with a SqliteStoreAndForwardSink draining to the gateway SendEvent writer. The alarm-write path // targets the SAME single gateway as the read path, so its connection (endpoint/key/TLS) is sourced // from the ServerHistorian section — NOT the legacy Wonderware-shaped AlarmHistorian host/port. // AlarmHistorianOptions still supplies the Enabled gate + the SQLite store-and-forward knobs // (consumed inside AddAlarmHistorian), so its Wonderware connection fields are intentionally unused. // Runtime owns the gating + Sqlite construction; the Host supplies the concrete gateway downstream // via the driver factory (which owns the package-client adapter). The writer builds its OWN gateway // channel — a second channel to the same sidecar: sharing one channel with the read path would force // the read-side GatewayHistorianDataSource to stop owning + disposing its client (regressing the read // cutover), and a second channel to a co-located sidecar is cheap (the gateway pools the historian // sessions server-side). var serverHistorianOptions = builder.Configuration .GetSection(ServerHistorianOptions.SectionName).Get() ?? new ServerHistorianOptions(); foreach (var warning in serverHistorianOptions.Validate()) Log.Warning("ServerHistorian misconfiguration detected at startup: {Warning}", warning); builder.Services.AddAlarmHistorian( builder.Configuration, (_, sp) => GatewayHistorian.CreateAlarmWriter(serverHistorianOptions, sp)); // Config-gated server-side HistoryRead backend. When the ServerHistorian section is enabled this // overrides the NullHistorianDataSource default from AddOtOpcUaRuntime (last registration wins) with // a read-only HistorianGateway-backed data source the node manager's HistoryRead overrides // block-bridge to. The factory lives in the Gateway driver (which owns the package-client adapter // and the ServerHistorianOptions -> client-options mapping); Runtime owns the gating. builder.Services.AddServerHistorian( builder.Configuration, (opts, sp) => GatewayHistorian.CreateDataSource(opts, sp)); // Continuous historization of driver (non-Galaxy) tag values. Gated on ContinuousHistorization:Enabled // AND the ServerHistorian gateway being configured: the recorder drains driver-tag live values to the // SAME single gateway's WriteLiveValues SQL path, sourcing endpoint/key/TLS from the ServerHistorian // section (this section carries only the recorder + outbox knobs). When both are on, register the durable // crash-safe outbox + the gateway-backed live-value writer here; WithOtOpcUaRuntimeActors (below) spawns // the recorder actor itself, gated on the same options. var continuousHistorizationOptions = builder.Configuration .GetSection(ContinuousHistorizationOptions.SectionName).Get() ?? new ContinuousHistorizationOptions(); foreach (var warning in continuousHistorizationOptions.Validate()) Log.Warning("ContinuousHistorization misconfiguration detected at startup: {Warning}", warning); if (serverHistorianOptions.Enabled && continuousHistorizationOptions.Enabled) { // Register the bound options so WithOtOpcUaRuntimeActors can gate the recorder spawn on Enabled. builder.Services.AddSingleton(continuousHistorizationOptions); // Durable, crash-safe FasterLog outbox (the historization crash boundary). Built via the factory so // the container OWNS disposal (FasterLogHistorizationOutbox is IDisposable). Binding the observable // outbox depth/dropped gauges here (once, on first resolution) keeps the live instance behind them. builder.Services.AddSingleton(_ => { var commitMode = Enum.TryParse( continuousHistorizationOptions.CommitMode, ignoreCase: true, out var parsedMode) ? parsedMode : HistorizationCommitMode.PerEntry; var outbox = new FasterLogHistorizationOutbox( continuousHistorizationOptions.OutboxPath, commitMode, continuousHistorizationOptions.CommitIntervalMs, continuousHistorizationOptions.Capacity); ContinuousHistorizationMetrics.BindOutbox(outbox); return outbox; }); // Gateway-backed live-value writer over its OWN gRPC channel to the same single gateway (a second // channel to a co-located sidecar is cheap — the gateway pools the historian sessions server-side). builder.Services.AddSingleton(sp => new GatewayHistorianValueWriter( HistorianGatewayClientAdapter.Create( serverHistorianOptions, sp.GetRequiredService()), sp.GetRequiredService>())); } // Bind every cross-platform driver factory before AddAkka resolves IDriverFactory — replaces // the F7-default NullDriverFactory with a real DriverFactoryRegistryAdapter so DriverHostActor // can materialise real IDriver instances on deploy. builder.Services.AddOtOpcUaDriverFactories(); // Deferred sink so Akka actors can resolve IOpcUaAddressSpaceSink at construction time — // the OPC UA hosted service swaps in a real SdkAddressSpaceSink once StandardServer has // started. Until then writes route through NullOpcUaAddressSpaceSink. builder.Services.AddSingleton(); builder.Services.AddSingleton(sp => sp.GetRequiredService()); // Same late-binding pattern for the ServiceLevel publisher — actor wants it at ctor time, // production SdkServiceLevelPublisher needs IServerInternal which only exists after Start. builder.Services.AddSingleton(); builder.Services.AddSingleton(sp => sp.GetRequiredService()); // F13c — bind UserName tokens to the same LDAP backend the Admin cookie/JWT flows use. // ILdapAuthService is registered by AddOtOpcUaAuth on admin nodes; on driver-only nodes // it isn't, so we register the LDAP options + service unconditionally for driver hosts // to keep parity. The LdapAdapter falls back to Deny on any backend error. // F8b — production virtual-tag evaluator (Roslyn-compiled scripts cached per expression). // Replaces the F8-default NullVirtualTagEvaluator so VirtualTagActor evaluates real user // scripts at runtime. builder.Services.AddSingleton(sp => new RoslynVirtualTagEvaluator( sp.GetRequiredService().CreateLogger(), sp.GetRequiredService())); builder.Services.AddSingleton(sp => sp.GetRequiredService()); // Script-log fan-out (Layer 0). The DPS publisher resolves the ActorSystem lazily so it never // races Akka startup. ScriptRootLogger wraps the composed pipeline (rolling scripts-*.log + // error mirror to the main log + script-logs DPS topic) for unambiguous DI resolution; Task 3 // injects it into the Roslyn evaluators above. var scriptLogFilePath = builder.Configuration["Scripting:LogFilePath"] ?? "logs/scripts-.log"; var scriptLogTopicMinLevel = Enum.TryParse( builder.Configuration["Scripting:LogTopicMinLevel"], ignoreCase: true, out var parsedLevel) ? parsedLevel : LogEventLevel.Information; builder.Services.AddSingleton(sp => new DpsScriptLogPublisher(() => sp.GetRequiredService())); builder.Services.AddSingleton(sp => new ScriptRootLogger( ScriptRootLoggerFactory.Build( sp.GetRequiredService(), scriptLogFilePath, scriptLogTopicMinLevel, Serilog.Log.Logger))); // TryAdd so a fused admin+driver node (where AddOtOpcUaAuth also registers these) ends up // with exactly one descriptor; on a driver-only node these are the sole registrations. // OtOpcUaLdapAuthService is the app ILdapAuthService (Enabled switch + DevStubMode over the // shared ZB.MOM.WW.Auth.Ldap service). The data-plane authenticator resolves IGroupRoleMapper // per call to turn the directory's groups into roles, so register it here for driver- // only nodes (AddOtOpcUaAuth registers it on admin nodes); ILdapGroupRoleMappingService it // depends on is already registered unconditionally by AddOtOpcUaConfigDb above. // Note: AddValidatedOptions is now registered unconditionally // above both role blocks so admin-only nodes also get fail-fast LDAP startup validation. builder.Services.TryAddSingleton(); builder.Services.TryAddScoped, OtOpcUaGroupRoleMapper>(); builder.Services.AddSingleton(); // Bind + validate the OPC UA host options the same way (fail-fast at start via ValidateOnStart) // and let OtOpcUaServerHostedService consume the validated IOptions instance rather than // re-binding the section imperatively. Defaults pass; this guards explicit prod/env overrides. builder.Services.AddValidatedOptions( builder.Configuration, "OpcUa"); // Lazy ActorSystem accessor so OtOpcUaServerHostedService can resolve the DistributedPubSub // mediator (for the inbound alarm-command router) without racing Akka startup — same pattern the // DpsScriptLogPublisher above uses. TryAdd so a fused admin+driver node registers it exactly once. builder.Services.TryAddSingleton>(sp => () => sp.GetRequiredService()); builder.Services.AddHostedService(); } // Akka cluster bootstrap. Role-specific singletons are registered on the AkkaConfigurationBuilder // from inside the configurator lambda. AddAkka spins the ActorSystem at host start. builder.Services.AddAkka("otopcua", (ab, sp) => { ab.WithOtOpcUaClusterBootstrap(sp); if (hasAdmin) { ab.WithOtOpcUaControlPlaneSingletons(); ab.WithOtOpcUaSignalRBridges(); } if (hasDriver) ab.WithOtOpcUaRuntimeActors(); }); if (hasAdmin) { // Auth + AdminUI surface only mounted on admin-role nodes. Driver-only nodes have no UI. builder.Services.AddOtOpcUaAuth(builder.Configuration); builder.Services.AddAdminUI(); // Test Connect probes back the AdminUI driver pages. The admin-operations singleton (role-pinned // to admin) resolves IEnumerable, so admin-only nodes — which skip the hasDriver // block above — must wire the probe set here too, or every Test Connect returns "No probe // registered". Idempotent on fused admin,driver nodes (TryAddEnumerable de-dups). builder.Services.AddOtOpcUaDriverProbes(); // Flow AuthenticationState through cascading parameters so works // in the static MainLayout footer and other components (e.g. Account.razor, Routes.razor). builder.Services.AddCascadingAuthenticationState(); builder.Services.AddSignalR(); builder.Services.AddOtOpcUaAdminClients(); } builder.Services.AddOtOpcUaHealth(); builder.Services.AddOtOpcUaObservability(builder.Configuration); var app = builder.Build(); // AddZbSerilog registers Serilog as the MEL logging provider but does NOT assign the static // Serilog.Log.Logger. Set it from the DI root logger so (1) static Log.* calls like the startup // banner below emit, and (2) Akka.Logger.Serilog's SerilogLogger — which writes to Log.Logger — // routes the actor graph's logs (DriverHostActor et al.) to the configured sinks. Must run before // app.RunAsync() starts the ActorSystem (the Akka logger captures Log.Logger at system start). Serilog.Log.Logger = app.Services.GetRequiredService(); app.UseSerilogRequestLogging(); // Razor class library static assets (_content//...) are served via endpoint // routing, NOT the UseStaticFiles middleware — so we MUST mark the static-asset // endpoints AllowAnonymous, otherwise the AddOtOpcUaAuth fallback RequireAuthenticatedUser // policy 401s every CSS/JS request and the login page renders unstyled. app.MapStaticAssets().AllowAnonymous(); if (hasAdmin) { app.UseAuthentication(); app.UseAuthorization(); app.UseAntiforgery(); app.MapOtOpcUaAuth(); app.MapAdminUI(); app.MapScriptAnalysisEndpoints(); app.MapOtOpcUaHubs(); // Headless deploy trigger for CI/scripts (API-key gated; disabled until Security:DeployApiKey set). app.MapOtOpcUaDeployApi(app.Configuration); } app.MapOtOpcUaHealth(); app.MapOtOpcUaMetrics(); Log.Information("OtOpcUa.Host starting with roles=[{Roles}] (admin={HasAdmin}, driver={HasDriver})", string.Join(",", roles), hasAdmin, hasDriver); await app.RunAsync(); namespace ZB.MOM.WW.OtOpcUa.Host { /// Re-exported for WebApplicationFactory<Program> integration tests (F1). public partial class Program; }