From 2697af31d19f1f27c825f6c8bb983b2046341422 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Tue, 26 May 2026 10:37:42 -0400 Subject: [PATCH] feat(opcua,host): #81 ServiceLevel SDK publisher MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- .../OpcUa/DeferredServiceLevelPublisher.cs | 19 ++++ .../OpcUa/OtOpcUaServerHostedService.cs | 17 +++- src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs | 6 ++ .../SdkServiceLevelPublisher.cs | 56 +++++++++++ .../DeferredServiceLevelPublisherTests.cs | 48 ++++++++++ .../SdkServiceLevelPublisherTests.cs | 94 +++++++++++++++++++ 6 files changed, 238 insertions(+), 2 deletions(-) create mode 100644 src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/DeferredServiceLevelPublisher.cs create mode 100644 src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/SdkServiceLevelPublisher.cs create mode 100644 tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/DeferredServiceLevelPublisherTests.cs create mode 100644 tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/SdkServiceLevelPublisherTests.cs diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/DeferredServiceLevelPublisher.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/DeferredServiceLevelPublisher.cs new file mode 100644 index 0000000..092845c --- /dev/null +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/DeferredServiceLevelPublisher.cs @@ -0,0 +1,19 @@ +namespace ZB.MOM.WW.OtOpcUa.Commons.OpcUa; + +/// +/// Late-binding adapter that holds an inner reference +/// swappable at runtime. Mirrors : Akka actors resolve +/// the publisher at DI time, but the production SdkServiceLevelPublisher only exists +/// after StandardServer.Start. The Host's hosted service swaps the inner once the SDK +/// is up; until then writes route through . +/// +public sealed class DeferredServiceLevelPublisher : IServiceLevelPublisher +{ + private volatile IServiceLevelPublisher _inner = NullServiceLevelPublisher.Instance; + + /// Swap the underlying publisher. Pass null to revert to the Null no-op. + public void SetInner(IServiceLevelPublisher? inner) => + _inner = inner ?? NullServiceLevelPublisher.Instance; + + public void Publish(byte serviceLevel) => _inner.Publish(serviceLevel); +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/OtOpcUaServerHostedService.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/OtOpcUaServerHostedService.cs index 28acb60..b86f706 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/OtOpcUaServerHostedService.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/OtOpcUaServerHostedService.cs @@ -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 _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(); @@ -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())); + } + + _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; } diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs index 8452a3c..3043d0a 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs @@ -60,6 +60,12 @@ if (hasDriver) 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 diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/SdkServiceLevelPublisher.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/SdkServiceLevelPublisher.cs new file mode 100644 index 0000000..9f7e4b3 --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/SdkServiceLevelPublisher.cs @@ -0,0 +1,56 @@ +using Microsoft.Extensions.Logging; +using Opc.Ua; +using Opc.Ua.Server; +using ZB.MOM.WW.OtOpcUa.Commons.OpcUa; + +namespace ZB.MOM.WW.OtOpcUa.OpcUaServer; + +/// +/// Production that writes the OPC UA Server object's +/// ServiceLevel Variable through the SDK. Clients reading +/// VariableIds.Server_ServiceLevel see the live value updated whenever the redundancy +/// state changes — that's the standard OPC UA non-transparent-redundancy signal callers use +/// to pick a primary. +/// +/// Uses (a ) and +/// its child variable, which the SDK populates +/// automatically during initialization. Writes are +/// guarded by so concurrent diagnostics scans +/// from the SDK don't fight with our update. +/// +public sealed class SdkServiceLevelPublisher : IServiceLevelPublisher +{ + private readonly IServerInternal _serverInternal; + private readonly ILogger _logger; + + public SdkServiceLevelPublisher(IServerInternal serverInternal, ILogger logger) + { + _serverInternal = serverInternal; + _logger = logger; + } + + public void Publish(byte serviceLevel) + { + var node = _serverInternal.ServerObject?.ServiceLevel; + if (node is null) + { + _logger.LogWarning("SdkServiceLevelPublisher: ServerObject.ServiceLevel unavailable; skipping write"); + return; + } + + try + { + lock (_serverInternal.DiagnosticsLock) + { + node.Value = serviceLevel; + node.Timestamp = DateTime.UtcNow; + node.StatusCode = StatusCodes.Good; + node.ClearChangeMasks(_serverInternal.DefaultSystemContext, includeChildren: false); + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, "SdkServiceLevelPublisher: write to Server.ServiceLevel threw"); + } + } +} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/DeferredServiceLevelPublisherTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/DeferredServiceLevelPublisherTests.cs new file mode 100644 index 0000000..d33391d --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/DeferredServiceLevelPublisherTests.cs @@ -0,0 +1,48 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Commons.OpcUa; + +namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests; + +public sealed class DeferredServiceLevelPublisherTests +{ + [Fact] + public void Publish_before_SetInner_is_a_safe_noop() + { + var deferred = new DeferredServiceLevelPublisher(); + + Should.NotThrow(() => deferred.Publish(123)); + } + + [Fact] + public void Publish_after_SetInner_routes_to_the_inner() + { + var recording = new RecordingPublisher(); + var deferred = new DeferredServiceLevelPublisher(); + deferred.SetInner(recording); + + deferred.Publish(200); + + recording.LastValue.ShouldBe((byte)200); + } + + [Fact] + public void SetInner_null_reverts_to_Null_publisher() + { + var recording = new RecordingPublisher(); + var deferred = new DeferredServiceLevelPublisher(); + deferred.SetInner(recording); + deferred.Publish(50); + + deferred.SetInner(null); + deferred.Publish(99); + + recording.LastValue.ShouldBe((byte)50, "writes after SetInner(null) must not reach the previous inner"); + } + + private sealed class RecordingPublisher : IServiceLevelPublisher + { + public byte? LastValue { get; private set; } + public void Publish(byte serviceLevel) => LastValue = serviceLevel; + } +} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/SdkServiceLevelPublisherTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/SdkServiceLevelPublisherTests.cs new file mode 100644 index 0000000..39a09f9 --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/SdkServiceLevelPublisherTests.cs @@ -0,0 +1,94 @@ +using Microsoft.Extensions.Logging.Abstractions; +using Opc.Ua.Server; +using Shouldly; +using Xunit; + +namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests; + +/// +/// #81 residual — verifies locates the standard +/// VariableIds.Server_ServiceLevel node through the SDK's DiagnosticsNodeManager and +/// writes the byte value. Boots a real on a free port so the +/// SDK populates its predefined diagnostics nodes — that's what production sees. +/// +public sealed class SdkServiceLevelPublisherTests : IDisposable +{ + private static CancellationToken Ct => TestContext.Current.CancellationToken; + + private readonly string _pkiRoot = Path.Combine( + Path.GetTempPath(), + $"otopcua-pki-{Guid.NewGuid():N}"); + + [Fact] + public async Task Publish_writes_value_to_Server_ServiceLevel_variable() + { + var server = new StandardServer(); + await using var host = new OpcUaApplicationHost( + new OpcUaApplicationHostOptions + { + ApplicationName = "OtOpcUa.SvcLevel", + ApplicationUri = $"urn:OtOpcUa.SvcLevel:{Guid.NewGuid():N}", + OpcUaPort = AllocateFreePort(), + PublicHostname = "localhost", + PkiStoreRoot = _pkiRoot, + }, + NullLogger.Instance); + + await host.StartAsync(server, Ct); + + var publisher = new SdkServiceLevelPublisher( + server.CurrentInstance, + NullLogger.Instance); + + publisher.Publish(200); + + var variable = server.CurrentInstance.ServerObject.ServiceLevel; + variable.ShouldNotBeNull("Server.ServiceLevel must be present in the address space"); + variable.Value.ShouldBe((byte)200); + } + + [Fact] + public async Task Publish_is_idempotent_when_called_multiple_times() + { + var server = new StandardServer(); + await using var host = new OpcUaApplicationHost( + new OpcUaApplicationHostOptions + { + ApplicationName = "OtOpcUa.SvcLevel.Idem", + ApplicationUri = $"urn:OtOpcUa.SvcLevel.Idem:{Guid.NewGuid():N}", + OpcUaPort = AllocateFreePort(), + PublicHostname = "localhost", + PkiStoreRoot = _pkiRoot, + }, + NullLogger.Instance); + + await host.StartAsync(server, Ct); + var publisher = new SdkServiceLevelPublisher( + server.CurrentInstance, + NullLogger.Instance); + + publisher.Publish(100); + publisher.Publish(150); + publisher.Publish(240); + + server.CurrentInstance.ServerObject.ServiceLevel.Value.ShouldBe((byte)240); + } + + private static int AllocateFreePort() + { + using var listener = new System.Net.Sockets.TcpListener(System.Net.IPAddress.Loopback, 0); + listener.Start(); + var port = ((System.Net.IPEndPoint)listener.LocalEndpoint).Port; + listener.Stop(); + return port; + } + + public void Dispose() + { + if (Directory.Exists(_pkiRoot)) + { + try { Directory.Delete(_pkiRoot, recursive: true); } + catch { /* best-effort */ } + } + } +}