feat(runtime): node-scoped ParseComposition filters address space by ClusterId
This commit is contained in:
@@ -205,6 +205,91 @@ 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>
|
||||
/// <param name="blob">The deployment artifact blob.</param>
|
||||
/// <param name="nodeId">This node's identity in "host:port" form.</param>
|
||||
/// <returns>The filtered composition per the node's scoping decision.</returns>
|
||||
public static Phase7CompositionResult ParseComposition(ReadOnlySpan<byte> blob, string nodeId)
|
||||
{
|
||||
var scope = ResolveClusterScope(blob, nodeId);
|
||||
if (scope.Mode == ClusterFilterMode.None) return ParseComposition(blob);
|
||||
if (scope.Mode == ClusterFilterMode.Suppress) return Empty();
|
||||
|
||||
var full = ParseComposition(blob);
|
||||
var sets = BuildClusterSets(blob, scope.ClusterId!);
|
||||
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(),
|
||||
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())
|
||||
{
|
||||
EquipmentTags = full.EquipmentTags.Where(t => sets.DriverIds.Contains(t.DriverInstanceId)).ToArray(),
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>The in-cluster id sets used to filter a composition.</summary>
|
||||
/// <param name="DriverIds">DriverInstanceIds whose row carries the in-scope ClusterId.</param>
|
||||
/// <param name="AreaIds">UnsAreaIds whose row carries the in-scope ClusterId.</param>
|
||||
/// <param name="EquipmentIds">EquipmentIds whose owning DriverInstanceId is in-cluster.</param>
|
||||
private sealed record ClusterSets(HashSet<string> DriverIds, HashSet<string> AreaIds, HashSet<string> EquipmentIds);
|
||||
|
||||
/// <summary>Build the in-cluster id sets used to filter a composition: DriverInstanceIds + UnsAreaIds
|
||||
/// that directly carry the ClusterId, plus EquipmentIds whose DriverInstanceId is in-cluster.</summary>
|
||||
/// <param name="blob">The deployment artifact blob.</param>
|
||||
/// <param name="clusterId">The node's ClusterId to scope to.</param>
|
||||
/// <returns>The resolved in-cluster id sets (empty on parse failure => empty composition).</returns>
|
||||
private static ClusterSets BuildClusterSets(ReadOnlySpan<byte> blob, string clusterId)
|
||||
{
|
||||
var driverIds = new HashSet<string>(StringComparer.Ordinal);
|
||||
var areaIds = new HashSet<string>(StringComparer.Ordinal);
|
||||
var equipmentIds = new HashSet<string>(StringComparer.Ordinal);
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(blob.ToArray());
|
||||
var root = doc.RootElement;
|
||||
CollectIdsWhereCluster(root, "DriverInstances", "DriverInstanceId", clusterId, driverIds);
|
||||
CollectIdsWhereCluster(root, "UnsAreas", "UnsAreaId", clusterId, areaIds);
|
||||
// Equipment carries no ClusterId — include it when its DriverInstanceId is in-cluster.
|
||||
if (root.TryGetProperty("Equipment", out var eq) && eq.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
foreach (var el in eq.EnumerateArray())
|
||||
{
|
||||
if (el.ValueKind != JsonValueKind.Object) continue;
|
||||
var di = el.TryGetProperty("DriverInstanceId", out var diEl) ? diEl.GetString() : null;
|
||||
var id = el.TryGetProperty("EquipmentId", out var idEl) ? idEl.GetString() : null;
|
||||
if (!string.IsNullOrWhiteSpace(id) && di is not null && driverIds.Contains(di))
|
||||
equipmentIds.Add(id!);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (JsonException) { /* empty sets => nothing matches => empty composition */ }
|
||||
return new ClusterSets(driverIds, areaIds, equipmentIds);
|
||||
}
|
||||
|
||||
/// <summary>Collect each row's <paramref name="idField"/> value from <paramref name="arrayName"/> whose
|
||||
/// ClusterId equals <paramref name="clusterId"/> (case-insensitive, matching the codebase convention).</summary>
|
||||
/// <param name="root">The artifact root element.</param>
|
||||
/// <param name="arrayName">The array property name to scan (e.g. "DriverInstances").</param>
|
||||
/// <param name="idField">The id field to collect from each in-cluster row.</param>
|
||||
/// <param name="clusterId">The ClusterId rows must match.</param>
|
||||
/// <param name="into">The set to collect matching ids into.</param>
|
||||
private static void CollectIdsWhereCluster(
|
||||
JsonElement root, string arrayName, string idField, string clusterId, HashSet<string> into)
|
||||
{
|
||||
if (!root.TryGetProperty(arrayName, out var arr) || arr.ValueKind != JsonValueKind.Array) return;
|
||||
foreach (var el in arr.EnumerateArray())
|
||||
{
|
||||
if (el.ValueKind != JsonValueKind.Object) continue;
|
||||
var cid = el.TryGetProperty("ClusterId", out var cEl) ? cEl.GetString() : null;
|
||||
if (!string.Equals(cid, clusterId, StringComparison.OrdinalIgnoreCase)) continue;
|
||||
var id = el.TryGetProperty(idField, out var idEl) ? idEl.GetString() : null;
|
||||
if (!string.IsNullOrWhiteSpace(id)) into.Add(id!);
|
||||
}
|
||||
}
|
||||
|
||||
private static Phase7CompositionResult Empty() => new(
|
||||
Array.Empty<UnsAreaProjection>(),
|
||||
Array.Empty<UnsLineProjection>(),
|
||||
|
||||
@@ -329,4 +329,64 @@ public sealed class DeploymentArtifactTests
|
||||
scope.Mode.ShouldBe(ClusterFilterMode.ScopeTo);
|
||||
scope.ClusterId.ShouldBe("MAIN");
|
||||
}
|
||||
|
||||
private static object MultiClusterSnapshotWithTags() => 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 },
|
||||
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 = "{}" },
|
||||
},
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void ParseComposition_scoped_keeps_only_my_clusters_drivers_and_tags()
|
||||
{
|
||||
var blob = BlobOf(MultiClusterSnapshotWithTags());
|
||||
|
||||
var main = DeploymentArtifact.ParseComposition(blob, "central-1:4053");
|
||||
main.DriverInstancePlans.Select(d => d.DriverInstanceId).ShouldBe(new[] { "main-galaxy" });
|
||||
main.GalaxyTags.Select(t => t.TagId).ShouldBe(new[] { "t-main" });
|
||||
|
||||
var siteA = DeploymentArtifact.ParseComposition(blob, "site-a-1:4053");
|
||||
siteA.DriverInstancePlans.Select(d => d.DriverInstanceId).ShouldBe(new[] { "sa-galaxy" });
|
||||
siteA.GalaxyTags.Select(t => t.TagId).ShouldBe(new[] { "t-sa" });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseComposition_scoped_unknown_node_is_empty()
|
||||
{
|
||||
var comp = DeploymentArtifact.ParseComposition(BlobOf(MultiClusterSnapshotWithTags()), "ghost-9:4053");
|
||||
comp.GalaxyTags.ShouldBeEmpty();
|
||||
comp.DriverInstancePlans.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseComposition_single_cluster_node_id_overload_matches_legacy()
|
||||
{
|
||||
var blob = BlobOf(new
|
||||
{
|
||||
Clusters = new[] { new { ClusterId = "MAIN" } },
|
||||
Nodes = new[] { new { NodeId = "n1:4053", ClusterId = "MAIN" } },
|
||||
DriverInstances = new[] { new { DriverInstanceId = "d1", DriverType = "Modbus", DriverConfig = "{}", ClusterId = "MAIN", NamespaceId = "ns" } },
|
||||
});
|
||||
DeploymentArtifact.ParseComposition(blob, "anything:4053").DriverInstancePlans.Count
|
||||
.ShouldBe(DeploymentArtifact.ParseComposition(blob).DriverInstancePlans.Count);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user