using Akka.Actor;
using Akka.Cluster.Tools.PublishSubscribe;
using Akka.Hosting;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Core.Authorization;
using ZB.MOM.WW.OtOpcUa.OpcUaServer;
using ZB.MOM.WW.OtOpcUa.OpcUaServer.Security;
using ZB.MOM.WW.OtOpcUa.Runtime;
using ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
using ZB.MOM.WW.OtOpcUa.Runtime.Historian;
using ZB.MOM.WW.OtOpcUa.Runtime.Redundancy;
using ZB.MOM.WW.OtOpcUa.Runtime.ScriptedAlarms;
namespace ZB.MOM.WW.OtOpcUa.Host.OpcUa;
///
/// Owns the OPC UA SDK lifecycle on driver-role hosts. Reads
/// from the OpcUa config section, boots
/// an through , then
/// swaps a real into the
/// singleton so OpcUaPublishActor's writes
/// start landing in the real address space.
///
/// Tests boot the OPC UA server directly via ; this
/// hosted service is the production wiring.
///
public sealed class OtOpcUaServerHostedService : IHostedService, IAsyncDisposable
{
private readonly OpcUaApplicationHostOptions _options;
private readonly DeferredAddressSpaceSink _deferredSink;
private readonly DeferredServiceLevelPublisher _deferredServiceLevel;
private readonly IOpcUaUserAuthenticator _userAuthenticator;
private readonly IHistorianDataSource _historianDataSource;
private readonly IDataPlaneAuthoritySource _dataPlaneAuthority;
private readonly DataPlaneAuthorizer? _dataPlaneAuthorizer;
private readonly ServerHistorianOptions _serverHistorianOptions;
private readonly AclOptions _aclOptions;
private readonly TimeProvider _time;
private readonly Func _actorSystemAccessor;
private readonly ActorRegistry _actorRegistry;
private readonly ILoggerFactory _loggerFactory;
private readonly ILogger _logger;
private readonly IDriverPrimaryRoleSource? _primaryRoleSource;
private readonly HistorianUpdaterHolder _historianUpdater;
private readonly IHistoryUpdateRoleView? _historyUpdateRoleView;
private OpcUaApplicationHost? _appHost;
private OtOpcUaSdkServer? _server;
private AclRevalidationTimer? _revalidationTimer;
///
/// Initializes a new instance of the OtOpcUaServerHostedService class.
///
/// The validated OPC UA host options (bound from the OpcUa section and validated at startup via ValidateOnStart).
/// The deferred address space sink that receives the real sink once the server is ready.
/// The deferred service level publisher that receives the real publisher once the server is ready.
/// The OPC UA user authenticator.
/// The server-side HistoryRead backend resolved from DI — the
/// NullHistorianDataSource default seeded by AddOtOpcUaRuntime (which runs on this driver
/// node, the same source the address-space sink + node-write gateway come from), or the configured
/// HistorianGateway read client when AddServerHistorian enabled it. Wired onto the node manager in
/// .
/// #520 stage 4 — the node's data-plane authorization inputs (its
/// cluster, the current auth generation, and the ACL grants), refreshed from every applied deployment
/// artifact. Wired onto the node manager in so the stage 5/6 enforcement seams
/// have a cluster identity to build a NodeScope with. Resolves to the empty source on a node with
/// no driver role.
/// App configuration; the ServerHistorian section is bound here to
/// read for the node manager. Bound directly
/// (not via IOptions) so the wiring is self-contained in this hosted service — the option's
/// default survives when the section is absent. The companion AddServerHistorian registration in
/// Program.cs reads the same section for the read-client gating; this is the read-path only.
/// Lazy accessor for the running , used to
/// resolve the DistributedPubSub mediator the inbound alarm-command router publishes through. Resolved
/// lazily (mirroring DpsScriptLogPublisher) so construction never races Akka startup.
/// The Akka.Hosting actor registry, used to resolve the local
/// DriverHostActor ref (DriverHostActorKey) the inbound node-write router Asks. Resolved
/// in after the runtime actors have been registered.
/// #520 stage 5 — the node-scoped ACL gate. Nullable, and null means
/// "not wired" rather than "deny": an unregistered gate must not lock a node out of writes to protect a
/// feature that is off by default. Once wired AND enabled, every unresolvable case inside it denies.
/// #520 / arch-review 03/S19 — the Security:Acl options, read for
/// . Required, not optional: the built-in DI
/// container does not fall back to a default for an unregistered service, the same trap the
/// IOpcUaUserAuthenticator and IDataPlaneAuthoritySource parameters carry comments
/// about.
/// The shared clock — the one instance the authorizer and the API-key store bound
/// also read. Its CreateTimer arms the revalidation tick.
/// The logger factory for creating loggers.
/// Gitea #597 — the data-plane Primary verdict DriverHostActor
/// publishes on every redundancy snapshot (the same one its native-ack route and device-write gates apply).
/// Wired onto the node manager so a native-condition Acknowledge on a non-Primary node is refused rather than
/// applied and then reverted. Optional: a node with no driver role registers none, and null means "allow" —
/// the single-node posture UnmanagedDriverPrimaryRoleSource also answers with.
/// The selected historian backend's optional HistoryUpdate capability.
/// Optional: when unregistered it resolves to and HistoryUpdate is
/// reported unsupported on every node.
/// Gitea #674 — the HistoryUpdate verdict DriverHostActor publishes (the
/// data-plane Primary verdict, closed until this node's first redundancy snapshot). Optional: registered on every
/// driver-role node; an admin-only node has none and wires no HistoryUpdate Primary gate (allow).
public OtOpcUaServerHostedService(
IOptions options,
DeferredAddressSpaceSink deferredSink,
DeferredServiceLevelPublisher deferredServiceLevel,
IOpcUaUserAuthenticator userAuthenticator,
IHistorianDataSource historianDataSource,
IDataPlaneAuthoritySource dataPlaneAuthority,
IConfiguration configuration,
Func actorSystemAccessor,
ActorRegistry actorRegistry,
DataPlaneAuthorizer? dataPlaneAuthorizer,
IOptions aclOptions,
TimeProvider time,
ILoggerFactory loggerFactory,
IDriverPrimaryRoleSource? primaryRoleSource = null,
HistorianUpdaterHolder? historianUpdater = null,
IHistoryUpdateRoleView? historyUpdateRoleView = null)
{
ArgumentNullException.ThrowIfNull(aclOptions);
ArgumentNullException.ThrowIfNull(time);
_dataPlaneAuthorizer = dataPlaneAuthorizer;
_aclOptions = aclOptions.Value;
_time = time;
_options = options.Value;
_deferredSink = deferredSink;
_deferredServiceLevel = deferredServiceLevel;
_userAuthenticator = userAuthenticator;
_historianDataSource = historianDataSource;
_dataPlaneAuthority = dataPlaneAuthority;
// Bind the ServerHistorian section directly (defaults survive when it's absent) — we only need
// MaxTieClusterOverfetch here, and binding it ourselves keeps the wiring in this one file.
_serverHistorianOptions =
configuration.GetSection(ServerHistorianOptions.SectionName).Get()
?? new ServerHistorianOptions();
_actorSystemAccessor = actorSystemAccessor;
_actorRegistry = actorRegistry;
_loggerFactory = loggerFactory;
_primaryRoleSource = primaryRoleSource;
_historianUpdater = historianUpdater ?? HistorianUpdaterHolder.None;
_historyUpdateRoleView = historyUpdateRoleView;
_logger = loggerFactory.CreateLogger();
}
///
/// Starts the OPC UA server asynchronously.
///
/// Cancellation token.
/// A task that represents the asynchronous operation.
public async Task StartAsync(CancellationToken cancellationToken)
{
_server = new OtOpcUaSdkServer(_loggerFactory.CreateLogger());
_appHost = new OpcUaApplicationHost(
_options,
_loggerFactory.CreateLogger(),
_userAuthenticator);
try
{
await _appHost.StartAsync(_server, cancellationToken).ConfigureAwait(false);
}
catch (Exception ex)
{
_logger.LogError(ex,
"OtOpcUaServerHostedService: SDK start failed; OpcUaPublishActor writes will continue to no-op");
// Don't rethrow — the rest of the host (admin UI, driver actors, etc.) can still boot.
// Operators see the failure via the logs + can correct config without a process bounce
// of the whole binary.
return;
}
if (_server.NodeManager is null)
{
_logger.LogWarning(
"OtOpcUaServerHostedService: SDK reported started but NodeManager is null; sink stays Null");
return;
}
// The HistoryUpdate backend MUST be wired before SetSink: the node manager decides each historized
// node's HistoryWrite AccessLevel bit at materialise time from whether an updater is present, and the
// SDK base admits a node to HistoryUpdate only when that bit is set. Installing the real sink below is
// what lets OpcUaPublishActor's deploy applies start materialising nodes, so assigning the updater
// first guarantees every node sees it (nodes are never retro-updated).
_server.SetHistorianDataUpdater(_historianUpdater.Updater);
_deferredSink.SetSink(new SdkAddressSpaceSink(_server.NodeManager));
// Wire the reverse-path inbound-alarm-command router: a client Acknowledge/Confirm/Shelve that
// passes the node manager's AlarmAck gate publishes the mapped AlarmCommand onto the cluster
// `alarm-commands` topic (same DistributedPubSub mediator the `alerts`/`script-logs` topics use).
// The Tell is fire-and-forget so the handler — which runs under the SDK's Lock — never blocks.
// The mediator is resolved per-publish via the lazy ActorSystem accessor so a transient cluster
// condition is tolerated and construction never raced Akka startup.
_server.SetAlarmCommandRouter(cmd =>
{
try
{
var mediator = DistributedPubSub.Get(_actorSystemAccessor()).Mediator;
mediator.Tell(new Publish(ScriptedAlarmHostActor.AlarmCommandsTopic, cmd));
}
catch (Exception ex)
{
// The router runs under the SDK Lock on a server thread; a cluster hiccup must not
// escape into the SDK's Call path. Log + drop — the client still gets Good for the
// node-state change; the missed command surfaces as a non-applied engine transition.
_logger.LogWarning(ex,
"OtOpcUaServerHostedService: failed to route inbound alarm command {Operation} for {AlarmId}",
cmd.Operation, cmd.AlarmId);
}
});
// Wire the reverse-path native-alarm-ack router (H6e): a client Acknowledge of a NATIVE (driver-fed,
// e.g. Galaxy) Part 9 condition that passes the node manager's AlarmAck gate routes the acknowledge to
// the owning driver child. The node manager maps the inbound ack to an OpcUaServer-local NativeAlarmAck;
// HERE the host maps that to the Runtime DriverHostActor.RouteNativeAlarmAck (field-for-field) and Tells
// it into the local DriverHostActor, which Primary-gates + resolves the owning DriverInstanceActor. The
// mapping happens at this boundary so Runtime stays free of an OpcUaServer dependency. The Tell is
// fire-and-forget so the handler — which runs under the SDK's Lock — never blocks. The DriverHostActor
// ref is resolved LAZILY per ack (this StartAsync runs before the Akka DriverHostActor registers, so a
// one-shot resolve here would always miss); by ack time the registry has it, mirroring the node-write
// gateway below.
_server.SetNativeAlarmAckRouter(nativeAck =>
{
try
{
if (_actorRegistry.TryGet(out var driverHost))
{
driverHost.Tell(new DriverHostActor.RouteNativeAlarmAck(
nativeAck.ConditionNodeId, nativeAck.Comment, nativeAck.OperatorUser));
}
else
{
// No driver host registered yet (admin-only node, or pre-registration race). The Part 9 ack
// already committed the local condition state; the missed routing surfaces as a non-applied
// upstream acknowledge, not a client-visible error.
_logger.LogWarning(
"OtOpcUaServerHostedService: native alarm ack for {ConditionNodeId} dropped — no DriverHostActor registered",
nativeAck.ConditionNodeId);
}
}
catch (Exception ex)
{
// The router runs under the SDK Lock on a server thread; a hiccup must not escape into the SDK's
// Call path. Log + drop — the client still gets Good for the condition-state change.
_logger.LogWarning(ex,
"OtOpcUaServerHostedService: failed to route native alarm ack for {ConditionNodeId}",
nativeAck.ConditionNodeId);
}
});
// Gitea #597: a native ack is accepted only on the Primary. The verdict is the one DriverHostActor
// publishes and gates the route above on, so a Secondary refuses (BadInvalidState) instead of applying an
// ack the driver will never see and then reverting it. A volatile property read — safe under the SDK Lock.
if (_primaryRoleSource is { } primaryRoleSource)
{
_server.SetNativeAlarmAckPrimaryGate(() => primaryRoleSource.ShouldActAsPrimary);
}
else
{
// Every driver node registers one (AddOtOpcUaRuntime), so this is a wiring regression, not a mode:
// say so rather than let a Secondary silently go back to accepting acks it will revert.
_logger.LogWarning(
"OtOpcUaServerHostedService: no IDriverPrimaryRoleSource registered — native-alarm acknowledges are accepted on every node regardless of redundancy role");
}
// Wire the reverse-path inbound operator-write gateway: a client write to a writable equipment-tag
// node that passes the node manager's WriteOperate gate routes the write to the owning driver child
// (RouteNodeWrite → NodeWriteResult) via the local DriverHostActor. The node manager calls the
// gateway's WriteAsync FIRE-AND-FORGET: the SDK's CustomNodeManager2.Write holds the node-manager
// Lock while invoking OnWriteValue, so a blocking Ask here would freeze ALL address-space operations
// (reads, subscription notifications, the publish path) for up to the Ask timeout. The gateway kicks
// off the Ask and resolves a NodeWriteOutcome; the node manager applies the client value optimistically
// and self-corrects (reverts to the pre-write value) when the device write comes back FAILED — but only
// while the node still holds the optimistic value, so a fresh driver poll is not clobbered. The
// DriverHostActor ref is resolved LAZILY per write (inside the gateway) — this hosted service's
// StartAsync runs before the Akka DriverHostActor registers, so a one-shot resolve here would always
// miss and leave every write unavailable. By write time (long after startup) the registry has it; a
// node that genuinely has no driver-host (admin-only, no writable driver nodes materialised) logs +
// resolves the write to "writes unavailable".
_server.SetNodeWriteGateway(new ActorNodeWriteGateway(
resolveDriverHost: () => _actorRegistry.TryGet(out var driverHost) ? driverHost : null,
logger: _loggerFactory.CreateLogger()));
// Wire the server-side read backend resolved from DI — the NullHistorianDataSource default (when
// the ServerHistorian section is disabled) or the configured HistorianGateway read client (when enabled).
// The node manager's HistoryRead overrides block-bridge to whatever source is set here.
_server.SetHistorianDataSource(_historianDataSource);
// #520 stage 4 — hand the node manager the node's data-plane authorization inputs. It exists because
// the node manager had NO cluster identity, and NodeScope.ClusterId is required while the evaluator
// fails closed on a mismatch — every scope it could have built would have been denied.
_server.SetDataPlaneAuthority(_dataPlaneAuthority);
// #520 stage 5 — hand it the node-scoped ACL gate. Still behaviour-neutral unless
// Security:Acl:Enabled is set: the gate reports itself disabled and every seam keeps its
// server-wide role check.
_server.SetDataPlaneAuthorizer(_dataPlaneAuthorizer);
if (_dataPlaneAuthorizer is { Enabled: true })
{
_logger.LogWarning(
"OtOpcUaServerHostedService: node ACL enforcement is ENABLED — EVERY data-plane operation "
+ "(read, browse, subscribe, write, alarm commands, HistoryRead) now requires a matching "
+ "NodeAcl grant. A principal with no grant can still connect and read the standard OPC UA "
+ "server nodes (ns=0: ServerStatus, NamespaceArray, ServiceLevel, ...) — those are served by "
+ "the SDK core node manager and are deliberately ungated because clients need them for "
+ "session setup — but it sees no plant data (raw/UNS namespaces).");
}
// Propagate the configured tie-cluster over-fetch bound onto the node manager so HistoryRead-Raw
// can page WITHIN an oversized tie cluster (more samples sharing one SourceTimestamp than the page
// cap) rather than failing the read. The option's default (65536) survives when the ServerHistorian
// section is absent; NodeManager is non-null here (guarded above).
_server.NodeManager.MaxTieClusterOverfetch = _serverHistorianOptions.MaxTieClusterOverfetch;
// Gitea #691: the ReturnBounds kill switch (default on).
_server.NodeManager.HonorReturnBounds = HonorReturnBounds;
// Gitea #694: the opt-in HistoryReadEvents horizon (default off = exact to each read's own window).
_server.NodeManager.EventReadHorizon = EventReadHorizon;
// Propagate the S3 HistoryRead concurrency bounds (arch-review 03/S3): bounded per-node fan-out
// within a batch, the process-wide concurrent-batch limiter, and the per-request deadline. Defaults
// (4 / 16 / 60 s) survive when the ServerHistorian section is absent.
_server.NodeManager.HistoryReadBatchParallelism = _serverHistorianOptions.HistoryReadBatchParallelism;
_server.NodeManager.MaxConcurrentHistoryReadBatches = _serverHistorianOptions.MaxConcurrentHistoryReadBatches;
_server.NodeManager.HistoryReadDeadline = _serverHistorianOptions.HistoryReadDeadline;
// Gitea #674: HistoryUpdate is Primary-only (BadInvalidState elsewhere, and closed until this node's first
// redundancy snapshot) and refuses a SourceTimestamp beyond now + HistoryUpdateMaxFutureSkew (BadOutOfRange),
// judged against the host clock.
var historyUpdate = BuildHistoryUpdatePosture();
_server.NodeManager.HistoryUpdatePrimaryGate = historyUpdate.PrimaryGate;
_server.NodeManager.HistoryUpdateMaxFutureSkew = historyUpdate.MaxFutureSkew;
_server.NodeManager.Clock = historyUpdate.Clock;
// ServiceLevel publisher needs IServerInternal — only available after Start.
if (_server.CurrentInstance is { } serverInternal)
{
_deferredServiceLevel.SetInner(new SdkServiceLevelPublisher(
serverInternal,
_loggerFactory.CreateLogger()));
}
// arch-review 03/S19 — LAST, after the node manager is known non-null and after the ACL gate is
// attached, so the first tick cannot land on a half-wired server.
StartRevalidationTimer();
_logger.LogInformation("OtOpcUaServerHostedService: SDK started, address-space + ServiceLevel sinks bound");
}
///
/// Gitea #691 — the ServerHistorian:HonorReturnBounds kill switch StartAsync applies to the node manager
/// (default true when the section or key is absent). Exposed so the wiring is testable without booting the SDK.
///
internal bool HonorReturnBounds => _serverHistorianOptions.HonorReturnBounds;
///
/// Gitea #694 — the ServerHistorian:EventReadHorizon StartAsync applies to the node manager (default
/// = none). Exposed so the wiring is testable without booting the SDK.
///
internal TimeSpan EventReadHorizon => _serverHistorianOptions.EventReadHorizon;
///
/// Gitea #674 — what StartAsync wires onto the node manager for HistoryUpdate: the Primary gate (read from
/// the on every call; null when none is registered, i.e. allow), the
/// future-timestamp bound from ServerHistorian:HistoryUpdateMaxFutureSkew, and the host clock it is judged
/// against. Separate from StartAsync so the wiring is testable without booting the SDK.
///
/// The posture to apply.
internal HistoryUpdatePosture BuildHistoryUpdatePosture()
{
Func? gate = _historyUpdateRoleView is { } view ? () => view.ShouldServeHistoryUpdate : null;
if (gate is null)
_logger.LogInformation(
"OtOpcUaServerHostedService: no IHistoryUpdateRoleView registered (no redundancy manager on this node) — HistoryUpdate is not Primary-gated");
return new HistoryUpdatePosture(gate, _serverHistorianOptions.HistoryUpdateMaxFutureSkew, _time);
}
/// Gitea #674 — the HistoryUpdate settings produces.
/// The node manager's HistoryUpdate Primary gate; null allows.
/// The future-timestamp bound; non-positive disables it.
/// The clock the bound is judged against.
internal sealed record HistoryUpdatePosture(Func? PrimaryGate, TimeSpan MaxFutureSkew, TimeProvider Clock);
///
/// Arms the clock-driven monitored-item revalidation tick (arch-review 03/S19).
///
///
/// Created unconditionally: the pass it drives early-returns unless the ACL gate is wired AND
/// Security:Acl:Enabled is on, so it stays inert on the default configuration, and
/// RevalidationIntervalSeconds = 0 arms no timer at all.
///
/// The node manager is resolved per tick rather than captured: is
/// replaced across a restart, and a captured reference would go on revalidating a dead
/// manager's item map — the same detach hazard the authority-source setter documents.
///
/// Separate from so the timer's lifecycle is testable without booting the SDK.
///
internal void StartRevalidationTimer()
{
// Dispose before overwriting. StartAsync runs again when a host restarts the service around the
// same instance, and an overwritten field would leave the previous timer armed with nothing left
// to dispose it — the same dispose-before-overwrite rule the driver reconnect paths carry.
_revalidationTimer?.Dispose();
_revalidationTimer = new AclRevalidationTimer(
() => _server?.NodeManager?.RevalidateMonitoredItems(),
TimeSpan.FromSeconds(_aclOptions.RevalidationIntervalSeconds),
_time,
_loggerFactory.CreateLogger());
}
///
/// Stops the OPC UA server asynchronously.
///
/// Cancellation token.
/// A task that represents the asynchronous operation.
public Task StopAsync(CancellationToken cancellationToken)
{
// FIRST, ahead of every detach below (arch-review 03/S19). A tick landing on a node manager whose
// authority and gate have already been cleared is the shape this whole teardown block exists to
// prevent — a stopping node sheds the trigger before it sheds what the trigger drives.
_revalidationTimer?.Dispose();
_revalidationTimer = null;
// Revert to Null adapters so any in-flight writes from a poison-pilled actor don't hit a
// half-disposed NodeManager.
_deferredSink.SetSink(null);
_deferredServiceLevel.SetInner(null);
// Restore the Null write gateway so a late client write doesn't Ask a stopping DriverHostActor.
_server?.SetNodeWriteGateway(null);
// Clear the native-alarm-ack router so a late ack doesn't Tell a stopping DriverHostActor.
_server?.SetNativeAlarmAckRouter(null);
_server?.SetNativeAlarmAckPrimaryGate(null);
_server?.SetHistoryUpdatePrimaryGate(null);
// Restore the Null historian so a late HistoryRead doesn't hit a disposed read client.
_server?.SetHistorianDataSource(null);
_server?.SetHistorianDataUpdater(null);
// Restore the empty authority so a late session activation cannot authorize against a stopping node.
_server?.SetDataPlaneAuthority(null);
// Detach the ACL gate too. Leaving it attached alongside an emptied authority would deny every late
// request rather than let the seams fall back to their role checks — a stopping node should shed
// enforcement in the same order it acquired it.
_server?.SetDataPlaneAuthorizer(null);
return Task.CompletedTask;
}
///
/// Disposes the hosted service and its resources asynchronously.
///
/// A task that represents the asynchronous operation.
public async ValueTask DisposeAsync()
{
// Before the app host, and idempotent with StopAsync — a host that disposes without stopping must
// not leave a tick armed against a server that is about to go away.
_revalidationTimer?.Dispose();
_revalidationTimer = null;
if (_appHost is not null) await _appHost.DisposeAsync();
}
}