diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DeploymentArtifact.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DeploymentArtifact.cs index 95ad6e9d..21aad8ef 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DeploymentArtifact.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DeploymentArtifact.cs @@ -206,11 +206,18 @@ public static class DeploymentArtifact } /// Cluster-scoped overload: the address-space composition a node should materialise given - /// its NodeId. Filters every projection to the node's own ClusterId (see ). + /// its NodeId. Filters every projection to the node's own ClusterId (see ). + /// When is supplied it is invoked with a human-readable message for each + /// kept equipment whose owning UNS line is NOT in the node's cluster — a cross-cluster binding that + /// violates the same-cluster invariant (decision #122) and would orphan the equipment folder. This is + /// detection only (observability); the equipment is still returned, since the upstream draft validator + /// is the authority that should prevent the binding in the first place. /// The deployment artifact blob. /// This node's identity in "host:port" form. + /// Optional diagnostic callback for cross-cluster orphan bindings; null disables the check. /// The filtered composition per the node's scoping decision. - public static Phase7CompositionResult ParseComposition(ReadOnlySpan blob, string nodeId) + public static Phase7CompositionResult ParseComposition( + ReadOnlySpan blob, string nodeId, Action? onInconsistency = null) { var scope = ResolveClusterScope(blob, nodeId); if (scope.Mode == ClusterFilterMode.None) return ParseComposition(blob); @@ -218,10 +225,27 @@ public static class DeploymentArtifact var full = ParseComposition(blob); var sets = BuildClusterSets(blob, scope.ClusterId!); + + var keptLines = full.UnsLines.Where(l => sets.AreaIds.Contains(l.UnsAreaId)).ToArray(); + var keptEquipment = full.EquipmentNodes.Where(e => sets.EquipmentIds.Contains(e.EquipmentId)).ToArray(); + + if (onInconsistency is not null) + { + var keptLineIds = keptLines.Select(l => l.UnsLineId).ToHashSet(StringComparer.OrdinalIgnoreCase); + foreach (var e in keptEquipment) + { + if (!string.IsNullOrEmpty(e.UnsLineId) && !keptLineIds.Contains(e.UnsLineId)) + onInconsistency( + $"equipment '{e.EquipmentId}' is in cluster '{scope.ClusterId}' (by its driver) but its " + + $"UNS line '{e.UnsLineId}' is not — a cross-cluster binding violates the same-cluster " + + "invariant (decision #122) and would orphan the equipment folder."); + } + } + return new Phase7CompositionResult( full.UnsAreas.Where(a => sets.AreaIds.Contains(a.UnsAreaId)).ToArray(), - full.UnsLines.Where(l => sets.AreaIds.Contains(l.UnsAreaId)).ToArray(), - full.EquipmentNodes.Where(e => sets.EquipmentIds.Contains(e.EquipmentId)).ToArray(), + keptLines, + keptEquipment, full.DriverInstancePlans.Where(d => sets.DriverIds.Contains(d.DriverInstanceId)).ToArray(), full.ScriptedAlarmPlans.Where(a => sets.EquipmentIds.Contains(a.EquipmentId)).ToArray(), full.GalaxyTags.Where(t => sets.DriverIds.Contains(t.DriverInstanceId)).ToArray()) 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 3d8a735c..d2a106c5 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/OpcUa/OpcUaPublishActor.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/OpcUa/OpcUaPublishActor.cs @@ -210,7 +210,8 @@ public sealed class OpcUaPublishActor : ReceiveActor ? LoadArtifact(depId) : LoadLatestArtifact(); var composition = _localNode is { } ln - ? DeploymentArtifact.ParseComposition(artifact, ln.Value) + ? DeploymentArtifact.ParseComposition(artifact, ln.Value, + inconsistency => _log.Warning("OpcUaPublish {Node}: cross-cluster binding — {Message}", ln, inconsistency)) : DeploymentArtifact.ParseComposition(artifact); var plan = Phase7Planner.Compute(_lastApplied, composition); diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DeploymentArtifactTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DeploymentArtifactTests.cs index c40986ac..f3e85652 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DeploymentArtifactTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DeploymentArtifactTests.cs @@ -389,4 +389,51 @@ public sealed class DeploymentArtifactTests DeploymentArtifact.ParseComposition(blob, "anything:4053").DriverInstancePlans.Count .ShouldBe(DeploymentArtifact.ParseComposition(blob).DriverInstancePlans.Count); } + + /// An artifact where an equipment's driver is in the node's cluster but its UNS line's area + /// is in another cluster: controls the area's ClusterId. + private static byte[] OrphanEquipmentBlob(string areaCluster) => BlobOf(new + { + Clusters = new[] { new { ClusterId = "MAIN" }, new { ClusterId = "SITE-A" } }, + Nodes = new[] { new { NodeId = "central-1:4053", ClusterId = "MAIN" } }, + DriverInstances = new[] + { + new { DriverInstanceId = "main-driver", DriverType = "Modbus", DriverConfig = "{}", ClusterId = "MAIN", NamespaceId = "main-ns" }, + }, + UnsAreas = new[] { new { UnsAreaId = "area-1", Name = "Area1", ClusterId = areaCluster } }, + UnsLines = new[] { new { UnsLineId = "line-1", UnsAreaId = "area-1", Name = "Line1" } }, + Equipment = new[] + { + new { EquipmentId = "equip-1", Name = "Equip1", UnsLineId = "line-1", DriverInstanceId = "main-driver" }, + }, + }); + + /// Verifies the inconsistency callback fires when a kept equipment's UNS line belongs to + /// another cluster (a cross-cluster orphan binding). + [Fact] + public void ParseComposition_scoped_flags_cross_cluster_orphan_equipment() + { + var warnings = new List(); + var comp = DeploymentArtifact.ParseComposition( + OrphanEquipmentBlob(areaCluster: "SITE-A"), "central-1:4053", warnings.Add); + + comp.EquipmentNodes.Select(e => e.EquipmentId).ShouldBe(new[] { "equip-1" }); // still returned + warnings.Count.ShouldBe(1); + warnings[0].ShouldContain("equip-1"); + warnings[0].ShouldContain("line-1"); + } + + /// Verifies the inconsistency callback does NOT fire when the equipment, its driver, and its + /// UNS line/area are all in the same cluster (the normal, invariant-respecting case). + [Fact] + public void ParseComposition_scoped_consistent_equipment_does_not_warn() + { + var warnings = new List(); + var comp = DeploymentArtifact.ParseComposition( + OrphanEquipmentBlob(areaCluster: "MAIN"), "central-1:4053", warnings.Add); + + comp.EquipmentNodes.Select(e => e.EquipmentId).ShouldBe(new[] { "equip-1" }); + comp.UnsLines.Select(l => l.UnsLineId).ShouldBe(new[] { "line-1" }); + warnings.ShouldBeEmpty(); + } }