From 8ce57e47a3e5b2e4aa370dac011385ee82b8253d Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sun, 7 Jun 2026 03:23:02 -0400 Subject: [PATCH] feat(runtime): OPC UA rebuild materialises only the node's ClusterId slice --- .../OpcUa/OpcUaPublishActor.cs | 4 +- .../OpcUa/OpcUaPublishActorRebuildTests.cs | 98 +++++++++++++++++++ 2 files changed, 101 insertions(+), 1 deletion(-) diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/OpcUa/OpcUaPublishActor.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/OpcUa/OpcUaPublishActor.cs index 9f005cc1..3d8a735c 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/OpcUa/OpcUaPublishActor.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/OpcUa/OpcUaPublishActor.cs @@ -209,7 +209,9 @@ public sealed class OpcUaPublishActor : ReceiveActor var artifact = msg.DeploymentId is { } depId ? LoadArtifact(depId) : LoadLatestArtifact(); - var composition = DeploymentArtifact.ParseComposition(artifact); + var composition = _localNode is { } ln + ? DeploymentArtifact.ParseComposition(artifact, ln.Value) + : DeploymentArtifact.ParseComposition(artifact); var plan = Phase7Planner.Compute(_lastApplied, composition); if (plan.IsEmpty) diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorRebuildTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorRebuildTests.cs index 618c1672..1a9760d7 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorRebuildTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorRebuildTests.cs @@ -98,6 +98,104 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase AwaitAssert(() => sink.RebuildCalls.ShouldBe(1), duration: TimeSpan.FromMilliseconds(500)); } + /// + /// Wiring proof for per-ClusterId scoping (Task 4): a multi-cluster artifact must + /// materialise ONLY the local node's cluster slice. Mirrors the multi-cluster artifact + /// shape exercised in DeploymentArtifactTests (MAIN + SITE-A, one Galaxy driver + + /// one SystemPlatform tag each). The scoped rebuild for the SITE-A node must surface the + /// SITE-A tag (t-sa → variable F.S1) and NOT MAIN's (t-main → + /// F.M1); the mirror holds for the MAIN node. Without the production scoping edit, + /// the unscoped parse would materialise BOTH variables on every node. + /// + [Fact] + public void Rebuild_materialises_only_the_nodes_cluster() + { + // --- SITE-A node: only the SITE-A tag's variable, never MAIN's. --- + var dbA = NewInMemoryDbFactory(); + var sinkA = new RecordingSink(); + var applierA = new Phase7Applier(sinkA, NullLogger.Instance); + SeedMultiClusterDeployment(dbA); + + var siteActor = Sys.ActorOf(OpcUaPublishActor.PropsForTests( + sink: sinkA, + dbFactory: dbA, + applier: applierA, + localNode: NodeId.Parse("site-a-1:4053"))); + + siteActor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId())); + + AwaitAssert(() => sinkA.RebuildCalls.ShouldBe(1), duration: TimeSpan.FromSeconds(2)); + // t-sa (Name "S1", FolderPath "F") → MxAccessRef "F.S1" → variable node "F.S1". + sinkA.Calls.ShouldContain("EV:F.S1"); + // t-main (MAIN cluster) must NOT leak onto the SITE-A node. + sinkA.Calls.ShouldNotContain("EV:F.M1"); + + // --- MAIN node: the mirror — only MAIN's tag's variable, never SITE-A's. --- + var dbM = NewInMemoryDbFactory(); + var sinkM = new RecordingSink(); + var applierM = new Phase7Applier(sinkM, NullLogger.Instance); + SeedMultiClusterDeployment(dbM); + + var mainActor = Sys.ActorOf(OpcUaPublishActor.PropsForTests( + sink: sinkM, + dbFactory: dbM, + applier: applierM, + localNode: NodeId.Parse("central-1:4053"))); + + mainActor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId())); + + AwaitAssert(() => sinkM.RebuildCalls.ShouldBe(1), duration: TimeSpan.FromSeconds(2)); + sinkM.Calls.ShouldContain("EV:F.M1"); + sinkM.Calls.ShouldNotContain("EV:F.S1"); + } + + /// + /// Seal a 2-cluster deployment (MAIN + SITE-A) whose artifact mirrors the multi-cluster + /// shape the composer emits: a Clusters + Nodes map, one SystemPlatform + /// namespace + Galaxy driver + Galaxy tag per cluster. Used by + /// . + /// + private static void SeedMultiClusterDeployment(IDbContextFactory dbFactory) + { + var artifact = JsonSerializer.SerializeToUtf8Bytes(new + { + Clusters = new[] { new { ClusterId = "MAIN" }, new { ClusterId = "SITE-A" } }, + Nodes = new[] + { + new { NodeId = "central-1:4053", ClusterId = "MAIN" }, + new { NodeId = "site-a-1:4053", ClusterId = "SITE-A" }, + }, + DriverInstances = new[] + { + new { DriverInstanceId = "main-galaxy", DriverType = "GalaxyMxGateway", DriverConfig = "{}", ClusterId = "MAIN", NamespaceId = "main-ns" }, + new { DriverInstanceId = "sa-galaxy", DriverType = "GalaxyMxGateway", DriverConfig = "{}", ClusterId = "SITE-A", NamespaceId = "sa-ns" }, + }, + Namespaces = new[] + { + new { NamespaceId = "main-ns", ClusterId = "MAIN", Kind = 1 }, // NamespaceKind.SystemPlatform + new { NamespaceId = "sa-ns", ClusterId = "SITE-A", Kind = 1 }, + }, + Tags = new[] + { + new { TagId = "t-main", DriverInstanceId = "main-galaxy", EquipmentId = (string?)null, Name = "M1", FolderPath = "F", DataType = "Boolean", TagConfig = "{}" }, + new { TagId = "t-sa", DriverInstanceId = "sa-galaxy", EquipmentId = (string?)null, Name = "S1", FolderPath = "F", DataType = "Boolean", TagConfig = "{}" }, + }, + ScriptedAlarms = Array.Empty(), + }); + + using var ctx = dbFactory.CreateDbContext(); + ctx.Deployments.Add(new Deployment + { + DeploymentId = Guid.NewGuid(), + RevisionHash = new string('b', 64), + Status = DeploymentStatus.Sealed, + CreatedBy = "test", + SealedAtUtc = DateTime.UtcNow, + ArtifactBlob = artifact, + }); + ctx.SaveChanges(); + } + private static void SeedDeployment( IDbContextFactory dbFactory, string[] equipmentIds,