226 lines
12 KiB
C#
226 lines
12 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.OpcUaServer;
|
|
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);
|
|
|
|
if (hasDriver)
|
|
{
|
|
builder.Services.AddOtOpcUaRuntime();
|
|
// 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>()));
|
|
builder.Services.AddSingleton<IVirtualTagEvaluator>(sp => sp.GetRequiredService<RoslynVirtualTagEvaluator>());
|
|
|
|
// F9b — same pattern for scripted-alarm predicates. The actor preserves prior state on
|
|
// any Failure result, so a misbehaving script can't flip Active/Inactive spuriously.
|
|
builder.Services.AddSingleton<RoslynScriptedAlarmEvaluator>(sp =>
|
|
new RoslynScriptedAlarmEvaluator(sp.GetRequiredService<ILoggerFactory>().CreateLogger<RoslynScriptedAlarmEvaluator>()));
|
|
builder.Services.AddSingleton<IScriptedAlarmEvaluator>(sp => sp.GetRequiredService<RoslynScriptedAlarmEvaluator>());
|
|
|
|
// 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)));
|
|
|
|
builder.Services.AddValidatedOptions<LdapOptions, LdapOptionsValidator>(builder.Configuration, LdapOptions.SectionName);
|
|
// 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.
|
|
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");
|
|
|
|
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();
|
|
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<Program></c> integration tests (F1).</summary>
|
|
public partial class Program;
|
|
}
|