From 2d97f241c013522b880cf4940e98f945d5e4c0be Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 20 Apr 2026 03:09:37 -0400 Subject: [PATCH] =?UTF-8?q?ADR-001=20wire-in=20=E2=80=94=20EquipmentNodeWa?= =?UTF-8?q?lker=20runs=20inside=20OpcUaApplicationHost=20before=20driver?= =?UTF-8?q?=20DiscoverAsync,=20closing=20tasks=20#212=20+=20#213.=20Comple?= =?UTF-8?q?tes=20the=20in-server=20half=20of=20the=20ADR-001=20Option=20A?= =?UTF-8?q?=20story:=20Task=20A=20(PR=20#153)=20shipped=20the=20pure-funct?= =?UTF-8?q?ion=20walker=20in=20Core.OpcUa;=20Task=20B=20(PR=20#154)=20ship?= =?UTF-8?q?ped=20the=20NodeScopeResolver=20+=20ScopePathIndexBuilder=20+?= =?UTF-8?q?=20evaluator-level=20authz=20proof.=20This=20PR=20lands=20the?= =?UTF-8?q?=20BuildAddressSpaceAsync=20wire-in=20the=20walker=20was=20alwa?= =?UTF-8?q?ys=20meant=20to=20plug=20into=20+=20a=20full-stack=20OPC=20UA?= =?UTF-8?q?=20client-browse=20integration=20test=20that=20proves=20the=20U?= =?UTF-8?q?NS=20folder=20skeleton=20is=20actually=20visible=20to=20real=20?= =?UTF-8?q?UA=20clients=20end-to-end,=20not=20just=20to=20the=20RecordingB?= =?UTF-8?q?uilder=20test=20double.=20OpcUaApplicationHost=20gains=20an=20o?= =?UTF-8?q?ptional=20ctor=20parameter=20equipmentContentLookup=20of=20type?= =?UTF-8?q?=20Func=3F=20=E2=80=94?= =?UTF-8?q?=20when=20supplied=20+=20non-null=20for=20a=20driver=20instance?= =?UTF-8?q?,=20EquipmentNodeWalker.Walk=20is=20invoked=20against=20that=20?= =?UTF-8?q?driver's=20node=20manager=20BEFORE=20GenericDriverNodeManager.B?= =?UTF-8?q?uildAddressSpaceAsync=20streams=20the=20driver's=20native=20Dis?= =?UTF-8?q?coverAsync=20output=20on=20top.=20Walker-first=20ordering=20mat?= =?UTF-8?q?ters:=20the=20UNS=20Area/Line/Equipment=20folder=20skeleton=20+?= =?UTF-8?q?=20Identification=20sub-folders=20+=20the=20five=20identifier?= =?UTF-8?q?=20properties=20(decision=20#121)=20are=20in=20place=20so=20dri?= =?UTF-8?q?ver-native=20references=20(driver-specific=20tag=20paths)=20lan?= =?UTF-8?q?d=20ALONGSIDE=20the=20UNS=20tree=20rather=20than=20racing=20it.?= =?UTF-8?q?=20Callers=20that=20don't=20supply=20a=20lookup=20(every=20exis?= =?UTF-8?q?ting=20pre-ADR-001=20test=20+=20the=20v1=20upgrade=20path)=20ge?= =?UTF-8?q?t=20identical=20behavior=20=E2=80=94=20the=20null-check=20is=20?= =?UTF-8?q?the=20backward-compat=20seam=20per=20the=20opt-in=20design=20sk?= =?UTF-8?q?etched=20in=20ADR-001.=20The=20lookup=20delegate=20is=20driver-?= =?UTF-8?q?instance-scoped,=20not=20server-scoped,=20so=20a=20single=20ser?= =?UTF-8?q?ver=20with=20multiple=20drivers=20can=20serve=20e.g.=20one=20Eq?= =?UTF-8?q?uipment-kind=20namespace=20(Galaxy=20proxy=20with=20a=20full=20?= =?UTF-8?q?UNS)=20alongside=20several=20native-kind=20namespaces=20(Modbus?= =?UTF-8?q?=20/=20AB=20CIP=20/=20TwinCAT=20/=20FOCAS=20that=20do=20not=20h?= =?UTF-8?q?ave=20their=20own=20UNS=20because=20decisions=20#116-#121=20sco?= =?UTF-8?q?pe=20UNS=20to=20Equipment-kind=20only).=20SealedBootstrap.Start?= =?UTF-8?q?=20will=20wire=20this=20lookup=20against=20the=20Config-DB=20sn?= =?UTF-8?q?apshot=20loader=20in=20a=20follow-up=20=E2=80=94=20the=20lookup?= =?UTF-8?q?=20plumbing=20lands=20first=20so=20that=20wiring=20reduces=20to?= =?UTF-8?q?=20one-line=20composition=20rather=20than=20a=20ctor-signature?= =?UTF-8?q?=20churn.=20New=20OpcUaEquipmentWalkerIntegrationTests=20spins?= =?UTF-8?q?=20up=20a=20real=20OtOpcUaServer=20on=20a=20non-default=20port?= =?UTF-8?q?=20with=20an=20EmptyDriver=20that=20registers=20with=20zero=20n?= =?UTF-8?q?ative=20content=20+=20a=20lookup=20that=20returns=20a=20seeded?= =?UTF-8?q?=20EquipmentNamespaceContent=20(one=20area=20warsaw=20/=20one?= =?UTF-8?q?=20line=20line-a=20/=20one=20equipment=20oven-3=20/=20one=20tag?= =?UTF-8?q?=20Temperature).=20An=20OPC=20UA=20client=20session=20connects?= =?UTF-8?q?=20anonymously=20against=20the=20un-secured=20endpoint,=20brows?= =?UTF-8?q?es=20the=20standard=20hierarchy,=20+=20asserts:=20(a)=20area=20?= =?UTF-8?q?folder=20warsaw=20contains=20line-a=20folder=20as=20a=20child;?= =?UTF-8?q?=20(b)=20line=20folder=20line-a=20contains=20oven-3=20folder=20?= =?UTF-8?q?as=20a=20child;=20(c)=20equipment=20folder=20oven-3=20contains?= =?UTF-8?q?=20EquipmentId=20+=20EquipmentUuid=20+=20MachineCode=20identifi?= =?UTF-8?q?er=20properties=20=E2=80=94=20ZTag=20+=20SAPID=20correctly=20ab?= =?UTF-8?q?sent=20because=20the=20fixture=20leaves=20them=20null=20per=20d?= =?UTF-8?q?ecision=20#121=20skip-when-null=20behavior;=20(d)=20the=20bound?= =?UTF-8?q?=20Tag=20emits=20a=20Variable=20node=20under=20the=20equipment?= =?UTF-8?q?=20folder=20with=20NodeId=20=3D=3D=20Tag.TagConfig=20(the=20wir?= =?UTF-8?q?e-level=20driver=20address)=20+=20the=20client=20can=20ReadValu?= =?UTF-8?q?e=20against=20it=20end-to-end=20through=20the=20DriverNodeManag?= =?UTF-8?q?er=20dispatch=20path.=20Because=20the=20EmptyDriver's=20Discove?= =?UTF-8?q?rAsync=20is=20a=20no-op=20the=20test=20proves=20UNS=20content?= =?UTF-8?q?=20came=20from=20the=20walker,=20not=20the=20driver=20=E2=80=94?= =?UTF-8?q?=20the=20original=20ADR-001=20question=20"what=20actually=20own?= =?UTF-8?q?s=20the=20browse=20tree"=20now=20has=20a=20mechanical=20answer?= =?UTF-8?q?=20visible=20at=20the=20OPC=20UA=20wire=20level.=20Test=20class?= =?UTF-8?q?=20uses=20its=20own=20port=20(48500+rand)=20+=20per-test=20PKI?= =?UTF-8?q?=20root=20so=20it=20runs=20in=20parallel=20with=20the=20existin?= =?UTF-8?q?g=20OpcUaServerIntegrationTests=20fixture=20(48400+rand)=20with?= =?UTF-8?q?out=20binding=20or=20cert=20collisions.=20Server=20project=20bu?= =?UTF-8?q?ilds=200=20errors;=20Server.Tests=20181/181=20(was=20179,=20+2?= =?UTF-8?q?=20new=20full-stack=20walker=20tests).=20Task=20#212=20+=20#213?= =?UTF-8?q?=20closed;=20the=20follow-up=20SealedBootstrap=20wiring=20is=20?= =?UTF-8?q?the=20natural=20next=20pickup=20because=20the=20ctor=20plumbing?= =?UTF-8?q?=20lands=20here=20+=20that=20becomes=20a=20narrow=20downstream?= =?UTF-8?q?=20PR.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.7 (1M context) --- .../OpcUa/OpcUaApplicationHost.cs | 25 ++- .../OpcUaEquipmentWalkerIntegrationTests.cs | 205 ++++++++++++++++++ 2 files changed, 229 insertions(+), 1 deletion(-) create mode 100644 tests/ZB.MOM.WW.OtOpcUa.Server.Tests/OpcUaEquipmentWalkerIntegrationTests.cs diff --git a/src/ZB.MOM.WW.OtOpcUa.Server/OpcUa/OpcUaApplicationHost.cs b/src/ZB.MOM.WW.OtOpcUa.Server/OpcUa/OpcUaApplicationHost.cs index cba0b49..a1491e1 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Server/OpcUa/OpcUaApplicationHost.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Server/OpcUa/OpcUaApplicationHost.cs @@ -29,6 +29,7 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable private readonly StaleConfigFlag? _staleConfigFlag; private readonly Func? _tierLookup; private readonly Func? _resilienceConfigLookup; + private readonly Func? _equipmentContentLookup; private readonly ILoggerFactory _loggerFactory; private readonly ILogger _logger; private ApplicationInstance? _application; @@ -43,7 +44,8 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable NodeScopeResolver? scopeResolver = null, StaleConfigFlag? staleConfigFlag = null, Func? tierLookup = null, - Func? resilienceConfigLookup = null) + Func? resilienceConfigLookup = null, + Func? equipmentContentLookup = null) { _options = options; _driverHost = driverHost; @@ -54,6 +56,7 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable _staleConfigFlag = staleConfigFlag; _tierLookup = tierLookup; _resilienceConfigLookup = resilienceConfigLookup; + _equipmentContentLookup = equipmentContentLookup; _loggerFactory = loggerFactory; _logger = logger; } @@ -103,11 +106,31 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable // Drive each driver's discovery through its node manager. The node manager IS the // IAddressSpaceBuilder; GenericDriverNodeManager captures alarm-condition sinks into // its internal map and wires OnAlarmEvent → sink routing. + // + // ADR-001 Option A — when an EquipmentNamespaceContent is supplied for an + // Equipment-kind driver, run the EquipmentNodeWalker BEFORE the driver's DiscoverAsync + // so the UNS folder skeleton (Area/Line/Equipment) + Identification sub-folders + + // the five identifier properties (decision #121) are in place. DiscoverAsync then + // streams the driver's native shape on top; Tag rows bound to Equipment already + // materialized via the walker don't get duplicated because the driver's DiscoverAsync + // output is authoritative for its own native references only. foreach (var nodeManager in _server.DriverNodeManagers) { var driverId = nodeManager.Driver.DriverInstanceId; try { + if (_equipmentContentLookup is not null) + { + var content = _equipmentContentLookup(driverId); + if (content is not null) + { + ZB.MOM.WW.OtOpcUa.Core.OpcUa.EquipmentNodeWalker.Walk(nodeManager, content); + _logger.LogInformation( + "UNS walker populated {Areas} area(s), {Lines} line(s), {Equipment} equipment, {Tags} tag(s) for driver {Driver}", + content.Areas.Count, content.Lines.Count, content.Equipment.Count, content.Tags.Count, driverId); + } + } + var generic = new GenericDriverNodeManager(nodeManager.Driver); await generic.BuildAddressSpaceAsync(nodeManager, ct).ConfigureAwait(false); _logger.LogInformation("Address space populated for driver {Driver}", driverId); diff --git a/tests/ZB.MOM.WW.OtOpcUa.Server.Tests/OpcUaEquipmentWalkerIntegrationTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Server.Tests/OpcUaEquipmentWalkerIntegrationTests.cs new file mode 100644 index 0000000..abfb4f4 --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Server.Tests/OpcUaEquipmentWalkerIntegrationTests.cs @@ -0,0 +1,205 @@ +using Microsoft.Extensions.Logging.Abstractions; +using Opc.Ua; +using Opc.Ua.Client; +using Opc.Ua.Configuration; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Configuration.Entities; +using ZB.MOM.WW.OtOpcUa.Configuration.Enums; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using ZB.MOM.WW.OtOpcUa.Core.Hosting; +using ZB.MOM.WW.OtOpcUa.Core.OpcUa; +using ZB.MOM.WW.OtOpcUa.Server.OpcUa; +using ZB.MOM.WW.OtOpcUa.Server.Security; + +namespace ZB.MOM.WW.OtOpcUa.Server.Tests; + +/// +/// End-to-end proof that ADR-001 Option A wire-in (#212) flows: when +/// is given an equipmentContentLookup that +/// returns a non-null , the walker runs BEFORE +/// the driver's DiscoverAsync + the UNS folder skeleton (Area → Line → Equipment) + +/// identifier properties are materialized into the driver's namespace + visible to an +/// OPC UA client via standard browse. +/// +[Trait("Category", "Integration")] +public sealed class OpcUaEquipmentWalkerIntegrationTests : IAsyncLifetime +{ + private static readonly int Port = 48500 + Random.Shared.Next(0, 99); + private readonly string _endpoint = $"opc.tcp://localhost:{Port}/OtOpcUaWalkerTest"; + private readonly string _pkiRoot = Path.Combine(Path.GetTempPath(), $"otopcua-walker-{Guid.NewGuid():N}"); + private const string DriverId = "galaxy-prod"; + + private DriverHost _driverHost = null!; + private OpcUaApplicationHost _server = null!; + + public async ValueTask InitializeAsync() + { + _driverHost = new DriverHost(); + await _driverHost.RegisterAsync(new EmptyDriver(DriverId), "{}", CancellationToken.None); + + var content = BuildFixture(); + + var options = new OpcUaServerOptions + { + EndpointUrl = _endpoint, + ApplicationName = "OtOpcUaWalkerTest", + ApplicationUri = "urn:OtOpcUa:Server:WalkerTest", + PkiStoreRoot = _pkiRoot, + AutoAcceptUntrustedClientCertificates = true, + HealthEndpointsEnabled = false, + }; + + _server = new OpcUaApplicationHost( + options, _driverHost, new DenyAllUserAuthenticator(), + NullLoggerFactory.Instance, NullLogger.Instance, + equipmentContentLookup: id => id == DriverId ? content : null); + + await _server.StartAsync(CancellationToken.None); + } + + public async ValueTask DisposeAsync() + { + await _server.DisposeAsync(); + await _driverHost.DisposeAsync(); + try { Directory.Delete(_pkiRoot, recursive: true); } catch { /* best-effort */ } + } + + [Fact] + public async Task Walker_Materializes_Area_Line_Equipment_Folders_Visible_Via_Browse() + { + using var session = await OpenSessionAsync(); + var nsIndex = (ushort)session.NamespaceUris.GetIndex($"urn:OtOpcUa:{DriverId}"); + + var areaFolder = new NodeId($"{DriverId}/warsaw", nsIndex); + var lineFolder = new NodeId($"{DriverId}/warsaw/line-a", nsIndex); + var equipmentFolder = new NodeId($"{DriverId}/warsaw/line-a/oven-3", nsIndex); + + BrowseChildren(session, areaFolder).ShouldContain(r => r.BrowseName.Name == "line-a"); + BrowseChildren(session, lineFolder).ShouldContain(r => r.BrowseName.Name == "oven-3"); + + var equipmentChildren = BrowseChildren(session, equipmentFolder); + equipmentChildren.ShouldContain(r => r.BrowseName.Name == "EquipmentId"); + equipmentChildren.ShouldContain(r => r.BrowseName.Name == "EquipmentUuid"); + equipmentChildren.ShouldContain(r => r.BrowseName.Name == "MachineCode"); + } + + [Fact] + public async Task Walker_Emits_Tag_Variable_Under_Equipment_Readable_By_Client() + { + using var session = await OpenSessionAsync(); + var nsIndex = (ushort)session.NamespaceUris.GetIndex($"urn:OtOpcUa:{DriverId}"); + + var tagNode = new NodeId("plcaddr-temperature", nsIndex); + var equipmentFolder = new NodeId($"{DriverId}/warsaw/line-a/oven-3", nsIndex); + + BrowseChildren(session, equipmentFolder).ShouldContain(r => r.BrowseName.Name == "Temperature"); + + var dv = session.ReadValue(tagNode); + dv.ShouldNotBeNull(); + } + + private static ReferenceDescriptionCollection BrowseChildren(ISession session, NodeId node) + { + session.Browse(null, null, node, 0, BrowseDirection.Forward, + ReferenceTypeIds.HierarchicalReferences, true, + (uint)NodeClass.Object | (uint)NodeClass.Variable, + out _, out var refs); + return refs; + } + + private static EquipmentNamespaceContent BuildFixture() + { + var area = new UnsArea { UnsAreaId = "area-1", ClusterId = "c-local", Name = "warsaw", GenerationId = 1 }; + var line = new UnsLine { UnsLineId = "line-a", UnsAreaId = "area-1", Name = "line-a", GenerationId = 1 }; + var oven = new Equipment + { + EquipmentRowId = Guid.NewGuid(), GenerationId = 1, + EquipmentId = "eq-oven-3", EquipmentUuid = Guid.NewGuid(), + DriverInstanceId = DriverId, UnsLineId = "line-a", Name = "oven-3", + MachineCode = "MC-oven-3", + }; + var tempTag = new Tag + { + TagRowId = Guid.NewGuid(), GenerationId = 1, TagId = "tag-1", + DriverInstanceId = DriverId, EquipmentId = "eq-oven-3", + Name = "Temperature", DataType = "Int32", + AccessLevel = TagAccessLevel.ReadWrite, TagConfig = "plcaddr-temperature", + }; + + return new EquipmentNamespaceContent( + Areas: new[] { area }, + Lines: new[] { line }, + Equipment: new[] { oven }, + Tags: new[] { tempTag }); + } + + private async Task OpenSessionAsync() + { + var cfg = new ApplicationConfiguration + { + ApplicationName = "OtOpcUaWalkerTestClient", + ApplicationUri = "urn:OtOpcUa:WalkerTestClient", + ApplicationType = ApplicationType.Client, + SecurityConfiguration = new SecurityConfiguration + { + ApplicationCertificate = new CertificateIdentifier + { + StoreType = CertificateStoreType.Directory, + StorePath = Path.Combine(_pkiRoot, "client-own"), + SubjectName = "CN=OtOpcUaWalkerTestClient", + }, + TrustedIssuerCertificates = new CertificateTrustList { StoreType = CertificateStoreType.Directory, StorePath = Path.Combine(_pkiRoot, "client-issuers") }, + TrustedPeerCertificates = new CertificateTrustList { StoreType = CertificateStoreType.Directory, StorePath = Path.Combine(_pkiRoot, "client-trusted") }, + RejectedCertificateStore = new CertificateTrustList { StoreType = CertificateStoreType.Directory, StorePath = Path.Combine(_pkiRoot, "client-rejected") }, + AutoAcceptUntrustedCertificates = true, + AddAppCertToTrustedStore = true, + }, + TransportConfigurations = new TransportConfigurationCollection(), + TransportQuotas = new TransportQuotas { OperationTimeout = 15000 }, + ClientConfiguration = new ClientConfiguration { DefaultSessionTimeout = 60000 }, + }; + await cfg.Validate(ApplicationType.Client); + cfg.CertificateValidator.CertificateValidation += (_, e) => e.Accept = true; + + var instance = new ApplicationInstance { ApplicationConfiguration = cfg, ApplicationType = ApplicationType.Client }; + await instance.CheckApplicationInstanceCertificate(true, CertificateFactory.DefaultKeySize); + + var selected = CoreClientUtils.SelectEndpoint(cfg, _endpoint, useSecurity: false); + var endpointConfig = EndpointConfiguration.Create(cfg); + var configuredEndpoint = new ConfiguredEndpoint(null, selected, endpointConfig); + + return await Session.Create(cfg, configuredEndpoint, false, "OtOpcUaWalkerTestClientSession", 60000, + new UserIdentity(new AnonymousIdentityToken()), null); + } + + /// + /// Driver that registers into the host + implements DiscoverAsync as a no-op. The + /// walker is the sole source of address-space content; if the UNS folders appear + /// under browse, they came from the wire-in (not from the driver's own discovery). + /// + private sealed class EmptyDriver : IDriver, ITagDiscovery, IReadable + { + public EmptyDriver(string id) { DriverInstanceId = id; } + public string DriverInstanceId { get; } + public string DriverType => "EmptyForWalkerTest"; + + public Task InitializeAsync(string driverConfigJson, CancellationToken ct) => Task.CompletedTask; + public Task ReinitializeAsync(string driverConfigJson, CancellationToken ct) => Task.CompletedTask; + public Task ShutdownAsync(CancellationToken ct) => Task.CompletedTask; + public DriverHealth GetHealth() => new(DriverState.Healthy, DateTime.UtcNow, null); + public long GetMemoryFootprint() => 0; + public Task FlushOptionalCachesAsync(CancellationToken ct) => Task.CompletedTask; + + public Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken ct) => Task.CompletedTask; + + public Task> ReadAsync( + IReadOnlyList fullReferences, CancellationToken cancellationToken) + { + var now = DateTime.UtcNow; + IReadOnlyList result = + fullReferences.Select(_ => new DataValueSnapshot(0, 0u, now, now)).ToArray(); + return Task.FromResult(result); + } + } +}