feat(opcua,host): #81 ServiceLevel SDK publisher

SdkServiceLevelPublisher writes Server.ServiceLevel through the SDK's
ServerObjectState — the standard OPC UA non-transparent-redundancy signal
clients use to pick a primary. Writes are guarded by DiagnosticsLock so
concurrent SDK diagnostics scans don't fight with our updates.

DeferredServiceLevelPublisher mirrors the DeferredAddressSpaceSink late-
binding pattern: Akka actors resolve IServiceLevelPublisher at construction,
hosted service swaps the SDK publisher in after StandardServer.Start. Host
Program.cs registers DeferredServiceLevelPublisher as the singleton bound
to IServiceLevelPublisher; OtOpcUaServerHostedService gets it injected and
fills it once IServerInternal is available.

Tests boot a real StandardServer on a free port (cross-platform), call
Publish, then verify ServerObject.ServiceLevel.Value reflects the write.
5 new tests; OpcUaServer suite now 45/45 green (was 40, +5).

Closes #81 residual. Unblocks Task 60 (OPC UA dual-endpoint + ServiceLevel
tests).
This commit is contained in:
Joseph Doherty
2026-05-26 10:37:42 -04:00
parent 52997ee164
commit 2697af31d1
6 changed files with 238 additions and 2 deletions
@@ -22,6 +22,7 @@ public sealed class OtOpcUaServerHostedService : IHostedService, IAsyncDisposabl
{
private readonly IConfiguration _configuration;
private readonly DeferredAddressSpaceSink _deferredSink;
private readonly DeferredServiceLevelPublisher _deferredServiceLevel;
private readonly IOpcUaUserAuthenticator _userAuthenticator;
private readonly ILoggerFactory _loggerFactory;
private readonly ILogger<OtOpcUaServerHostedService> _logger;
@@ -32,11 +33,13 @@ public sealed class OtOpcUaServerHostedService : IHostedService, IAsyncDisposabl
public OtOpcUaServerHostedService(
IConfiguration configuration,
DeferredAddressSpaceSink deferredSink,
DeferredServiceLevelPublisher deferredServiceLevel,
IOpcUaUserAuthenticator userAuthenticator,
ILoggerFactory loggerFactory)
{
_configuration = configuration;
_deferredSink = deferredSink;
_deferredServiceLevel = deferredServiceLevel;
_userAuthenticator = userAuthenticator;
_loggerFactory = loggerFactory;
_logger = loggerFactory.CreateLogger<OtOpcUaServerHostedService>();
@@ -75,14 +78,24 @@ public sealed class OtOpcUaServerHostedService : IHostedService, IAsyncDisposabl
}
_deferredSink.SetSink(new SdkAddressSpaceSink(_server.NodeManager));
_logger.LogInformation("OtOpcUaServerHostedService: SDK started, address-space sink bound");
// ServiceLevel publisher needs IServerInternal — only available after Start.
if (_server.CurrentInstance is { } serverInternal)
{
_deferredServiceLevel.SetInner(new SdkServiceLevelPublisher(
serverInternal,
_loggerFactory.CreateLogger<SdkServiceLevelPublisher>()));
}
_logger.LogInformation("OtOpcUaServerHostedService: SDK started, address-space + ServiceLevel sinks bound");
}
public Task StopAsync(CancellationToken cancellationToken)
{
// Revert to Null sink so any in-flight writes from a poison-pilled actor don't hit a
// 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);
return Task.CompletedTask;
}
@@ -60,6 +60,12 @@ if (hasDriver)
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