fix(runtime): flag cross-cluster orphan-equipment bindings on rebuild
v2-ci / build (push) Failing after 42s
v2-ci / unit-tests (tests/Core/ZB.MOM.WW.OtOpcUa.Cluster.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests) (push) Has been skipped
v2-ci / integration (tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests) (push) Has been skipped
v2-ci / integration (tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests) (push) Has been skipped
v2-ci / build (push) Failing after 42s
v2-ci / unit-tests (tests/Core/ZB.MOM.WW.OtOpcUa.Cluster.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests) (push) Has been skipped
v2-ci / integration (tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests) (push) Has been skipped
v2-ci / integration (tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests) (push) Has been skipped
ParseComposition(blob, nodeId, onInconsistency?) detects a kept equipment whose UNS line belongs to another cluster (a same-cluster-invariant violation that would orphan the equipment folder) and reports it via an optional callback, wired to OpcUaPublishActor's logger. Detection-only; the upstream draft validator remains the authority. Adds two unit tests.
This commit is contained in:
@@ -206,11 +206,18 @@ public static class DeploymentArtifact
|
||||
}
|
||||
|
||||
/// <summary>Cluster-scoped overload: the address-space composition a node should materialise given
|
||||
/// its NodeId. Filters every projection to the node's own ClusterId (see <see cref="ResolveClusterScope"/>).</summary>
|
||||
/// its NodeId. Filters every projection to the node's own ClusterId (see <see cref="ResolveClusterScope"/>).
|
||||
/// When <paramref name="onInconsistency"/> 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.</summary>
|
||||
/// <param name="blob">The deployment artifact blob.</param>
|
||||
/// <param name="nodeId">This node's identity in "host:port" form.</param>
|
||||
/// <param name="onInconsistency">Optional diagnostic callback for cross-cluster orphan bindings; null disables the check.</param>
|
||||
/// <returns>The filtered composition per the node's scoping decision.</returns>
|
||||
public static Phase7CompositionResult ParseComposition(ReadOnlySpan<byte> blob, string nodeId)
|
||||
public static Phase7CompositionResult ParseComposition(
|
||||
ReadOnlySpan<byte> blob, string nodeId, Action<string>? 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())
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -389,4 +389,51 @@ public sealed class DeploymentArtifactTests
|
||||
DeploymentArtifact.ParseComposition(blob, "anything:4053").DriverInstancePlans.Count
|
||||
.ShouldBe(DeploymentArtifact.ParseComposition(blob).DriverInstancePlans.Count);
|
||||
}
|
||||
|
||||
/// <summary>An artifact where an equipment's driver is in the node's cluster but its UNS line's area
|
||||
/// is in another cluster: <paramref name="areaCluster"/> controls the area's ClusterId.</summary>
|
||||
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" },
|
||||
},
|
||||
});
|
||||
|
||||
/// <summary>Verifies the inconsistency callback fires when a kept equipment's UNS line belongs to
|
||||
/// another cluster (a cross-cluster orphan binding).</summary>
|
||||
[Fact]
|
||||
public void ParseComposition_scoped_flags_cross_cluster_orphan_equipment()
|
||||
{
|
||||
var warnings = new List<string>();
|
||||
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");
|
||||
}
|
||||
|
||||
/// <summary>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).</summary>
|
||||
[Fact]
|
||||
public void ParseComposition_scoped_consistent_equipment_does_not_warn()
|
||||
{
|
||||
var warnings = new List<string>();
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user