Files
lmxopcua/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs
T
Joseph Doherty 6600ce9940 feat(otopcua): bridge Akka actor logs into Serilog
DriverHostActor/DriverInstanceActor and cluster events log via Akka's ILoggingAdapter, which had no Serilog bridge — under the Windows service host the default StandardOutLogger output is discarded, making the driver-role actor graph invisible (this masked the data-plane diagnosis).

- Add Akka.Logger.Serilog 1.5.60 (deps satisfied by Akka 1.5.62 / Serilog 4.3.1).
- WithOtOpcUaClusterBootstrap: ConfigureLoggers(DebugLevel; ClearLoggers(); AddLogger<SerilogLogger>()) — Akka.Hosting owns logger setup, so HOCON akka.loggers alone is not honored.
- Program.cs: set static Serilog.Log.Logger from the DI root logger after Build() (AddZbSerilog registers the MEL provider but not the static logger, which the Akka SerilogLogger and the startup banner both need).
2026-06-26 06:00:05 -04:00

273 lines
15 KiB
C#

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.Wonderware.Client;
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/<libname>/...) 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<LdapOptions, LdapOptionsValidator>(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 Wonderware TCP writer. The writer is
// injected here because the Host is the only project that references the Wonderware client —
// Runtime owns the gating + Sqlite construction, the Host supplies the concrete downstream.
builder.Services.AddAlarmHistorian(
builder.Configuration,
(opts, sp) => new WonderwareHistorianClient(
new WonderwareHistorianClientOptions(opts.Host, opts.Port, opts.SharedSecret)
{
UseTls = opts.UseTls, ServerCertThumbprint = opts.ServerCertThumbprint,
},
sp.GetService<ILogger<WonderwareHistorianClient>>()));
// 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 WonderwareHistorianClient the node manager's HistoryRead overrides block-bridge to.
// The client is supplied here because the Host is the only project that references the Wonderware
// client — Runtime owns the gating, the Host supplies the concrete read downstream.
builder.Services.AddServerHistorian(
builder.Configuration,
(opts, sp) => new WonderwareHistorianClient(
new WonderwareHistorianClientOptions(opts.Host, opts.Port, opts.SharedSecret)
{
UseTls = opts.UseTls, ServerCertThumbprint = opts.ServerCertThumbprint,
},
sp.GetService<ILogger<WonderwareHistorianClient>>()));
// 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<DeferredAddressSpaceSink>();
builder.Services.AddSingleton<IOpcUaAddressSpaceSink>(sp =>
sp.GetRequiredService<DeferredAddressSpaceSink>());
// 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<DeferredServiceLevelPublisher>();
builder.Services.AddSingleton<IServiceLevelPublisher>(sp =>
sp.GetRequiredService<DeferredServiceLevelPublisher>());
// 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<RoslynVirtualTagEvaluator>(sp =>
new RoslynVirtualTagEvaluator(
sp.GetRequiredService<ILoggerFactory>().CreateLogger<RoslynVirtualTagEvaluator>(),
sp.GetRequiredService<ScriptRootLogger>()));
builder.Services.AddSingleton<IVirtualTagEvaluator>(sp => sp.GetRequiredService<RoslynVirtualTagEvaluator>());
// 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<LogEventLevel>(
builder.Configuration["Scripting:LogTopicMinLevel"], ignoreCase: true, out var parsedLevel)
? parsedLevel
: LogEventLevel.Information;
builder.Services.AddSingleton<IScriptLogPublisher>(sp =>
new DpsScriptLogPublisher(() => sp.GetRequiredService<ActorSystem>()));
builder.Services.AddSingleton(sp => new ScriptRootLogger(
ScriptRootLoggerFactory.Build(
sp.GetRequiredService<IScriptLogPublisher>(), 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
// <string> 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<LdapOptions, LdapOptionsValidator> is now registered unconditionally
// above both role blocks so admin-only nodes also get fail-fast LDAP startup validation.
builder.Services.TryAddSingleton<ILdapAuthService, OtOpcUaLdapAuthService>();
builder.Services.TryAddScoped<IGroupRoleMapper<string>, OtOpcUaGroupRoleMapper>();
builder.Services.AddSingleton<IOpcUaUserAuthenticator, LdapOpcUaUserAuthenticator>();
// 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<OpcUaApplicationHostOptions, OpcUaApplicationHostOptionsValidator>(
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<Func<ActorSystem>>(sp => () => sp.GetRequiredService<ActorSystem>());
builder.Services.AddHostedService<OtOpcUaServerHostedService>();
}
// 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<IDriverProbe>, 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 <AuthorizeView/> 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<Serilog.ILogger>();
app.UseSerilogRequestLogging();
// Razor class library static assets (_content/<libname>/...) 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>();
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
{
/// <summary>Re-exported for <c>WebApplicationFactory&lt;Program&gt;</c> integration tests (F1).</summary>
public partial class Program;
}