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 76f6f292..84b8966a 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DeploymentArtifact.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DeploymentArtifact.cs @@ -18,6 +18,27 @@ public sealed record DriverInstanceSpec( string DriverConfig, string? ClusterId = null); +/// How a node should scope a deployment artifact to its own ClusterId. +public enum ClusterFilterMode +{ + /// Apply everything (single-cluster / legacy deployments). + None, + + /// Filter the artifact to the node's own ClusterId. + ScopeTo, + + /// Apply nothing (node's cluster row not found in a multi-cluster artifact). + Suppress, +} + +/// Resolved scoping decision for a node against an artifact. +/// +/// None = apply everything (single-cluster / legacy); ScopeTo = filter to ; +/// Suppress = apply nothing. +/// +/// The node's ClusterId when is ScopeTo; otherwise null. +public readonly record struct ClusterScope(ClusterFilterMode Mode, string? ClusterId); + public static class DeploymentArtifact { private static readonly JsonSerializerOptions JsonOptions = new() @@ -58,6 +79,73 @@ public static class DeploymentArtifact } } + /// + /// Resolve how a node should scope a deployment artifact to its own ClusterId. Single-cluster + /// (or legacy) artifacts resolve to so every existing + /// deployment applies unchanged. In a multi-cluster artifact the node's ClusterNode row + /// (matched by ) selects its + /// ClusterId; a missing row resolves to . Empty / + /// malformed blobs resolve to (lenient, matching the + /// other parsers). + /// + /// The deployment artifact blob to inspect. + /// The node's identity (e.g. "central-1:4053") to match against Nodes. + /// The resolved decision for this node. + public static ClusterScope ResolveClusterScope(ReadOnlySpan blob, string nodeId) + { + if (blob.IsEmpty) return new ClusterScope(ClusterFilterMode.None, null); + try + { + using var doc = JsonDocument.Parse(blob.ToArray()); + var root = doc.RootElement; + var clusterCount = root.TryGetProperty("Clusters", out var cl) && cl.ValueKind == JsonValueKind.Array + ? cl.GetArrayLength() : 0; + if (clusterCount <= 1) return new ClusterScope(ClusterFilterMode.None, null); + + string? myCluster = null; + if (root.TryGetProperty("Nodes", out var nodes) && nodes.ValueKind == JsonValueKind.Array) + { + foreach (var el in nodes.EnumerateArray()) + { + if (el.ValueKind != JsonValueKind.Object) continue; + var nid = el.TryGetProperty("NodeId", out var nEl) ? nEl.GetString() : null; + if (!string.Equals(nid, nodeId, StringComparison.Ordinal)) continue; + myCluster = el.TryGetProperty("ClusterId", out var cEl) ? cEl.GetString() : null; + break; + } + } + return string.IsNullOrWhiteSpace(myCluster) + ? new ClusterScope(ClusterFilterMode.Suppress, null) + : new ClusterScope(ClusterFilterMode.ScopeTo, myCluster); + } + catch (JsonException) + { + return new ClusterScope(ClusterFilterMode.None, null); + } + } + + /// + /// Parse a deployment artifact blob into the driver-instance specs a specific node should + /// spawn, scoped to its own ClusterId via . Single-cluster / + /// legacy artifacts return every spec; a multi-cluster artifact returns only the matching + /// cluster's specs (or none when the node's row is absent). + /// + /// The deployment artifact blob to parse. + /// The node's identity (e.g. "central-1:4053") used to resolve cluster scope. + /// The driver-instance specs this node should spawn. + public static IReadOnlyList ParseDriverInstances(ReadOnlySpan blob, string nodeId) + { + var scope = ResolveClusterScope(blob, nodeId); + var all = ParseDriverInstances(blob); + return scope.Mode switch + { + ClusterFilterMode.Suppress => Array.Empty(), + ClusterFilterMode.ScopeTo => all.Where( + s => string.Equals(s.ClusterId, scope.ClusterId, StringComparison.Ordinal)).ToArray(), + _ => all, + }; + } + private static DriverInstanceSpec? TryReadSpec(JsonElement el) { var rowId = el.TryGetProperty("DriverInstanceRowId", out var rowEl) 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 bd748c34..42686a16 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 @@ -1,3 +1,4 @@ +using System.Linq; using System.Text; using System.Text.Json; using Shouldly; @@ -8,6 +9,78 @@ namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers; public sealed class DeploymentArtifactTests { + private static byte[] BlobOf(object snapshot) => + System.Text.Json.JsonSerializer.SerializeToUtf8Bytes(snapshot); + + private static object MultiClusterSnapshot() => 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 { DriverInstanceRowId = Guid.NewGuid(), DriverInstanceId = "main-galaxy", Name = "g", DriverType = "GalaxyMxGateway", Enabled = true, DriverConfig = "{}", ClusterId = "MAIN", NamespaceId = "main-ns" }, + new { DriverInstanceRowId = Guid.NewGuid(), DriverInstanceId = "sa-modbus", Name = "m", DriverType = "Modbus", Enabled = true, DriverConfig = "{}", ClusterId = "SITE-A", NamespaceId = "sa-ns" }, + }, + }; + + /// Verifies a single-cluster artifact resolves to None (apply everything). + [Fact] + public void ResolveClusterScope_single_cluster_artifact_returns_None() + { + var blob = BlobOf(new { Clusters = new[] { new { ClusterId = "MAIN" } }, Nodes = Array.Empty() }); + var scope = DeploymentArtifact.ResolveClusterScope(blob, "central-1:4053"); + scope.Mode.ShouldBe(ClusterFilterMode.None); + } + + /// Verifies a multi-cluster artifact scopes a known node to its own ClusterId. + [Fact] + public void ResolveClusterScope_multi_cluster_known_node_scopes_to_its_cluster() + { + var scope = DeploymentArtifact.ResolveClusterScope(BlobOf(MultiClusterSnapshot()), "site-a-1:4053"); + scope.Mode.ShouldBe(ClusterFilterMode.ScopeTo); + scope.ClusterId.ShouldBe("SITE-A"); + } + + /// Verifies a multi-cluster artifact suppresses an unknown node. + [Fact] + public void ResolveClusterScope_multi_cluster_unknown_node_suppresses() + { + var scope = DeploymentArtifact.ResolveClusterScope(BlobOf(MultiClusterSnapshot()), "ghost-9:4053"); + scope.Mode.ShouldBe(ClusterFilterMode.Suppress); + } + + /// Verifies the scoped parse returns only the node's own cluster's drivers. + [Fact] + public void ParseDriverInstances_scoped_returns_only_my_clusters_drivers() + { + var specs = DeploymentArtifact.ParseDriverInstances(BlobOf(MultiClusterSnapshot()), "central-1:4053"); + specs.Select(s => s.DriverInstanceId).ShouldBe(new[] { "main-galaxy" }); + } + + /// Verifies the scoped parse returns nothing for an unknown node. + [Fact] + public void ParseDriverInstances_scoped_unknown_node_returns_empty() + { + var specs = DeploymentArtifact.ParseDriverInstances(BlobOf(MultiClusterSnapshot()), "ghost-9:4053"); + specs.ShouldBeEmpty(); + } + + /// Verifies the scoped parse returns all drivers for a single-cluster artifact. + [Fact] + public void ParseDriverInstances_scoped_single_cluster_returns_all() + { + var blob = BlobOf(new + { + Clusters = new[] { new { ClusterId = "MAIN" } }, + Nodes = new[] { new { NodeId = "n1:4053", ClusterId = "MAIN" } }, + DriverInstances = new[] { new { DriverInstanceRowId = Guid.NewGuid(), DriverInstanceId = "d1", Name = "d", DriverType = "Modbus", Enabled = true, DriverConfig = "{}", ClusterId = "MAIN" } }, + }); + DeploymentArtifact.ParseDriverInstances(blob, "anything:4053").Select(s => s.DriverInstanceId).ShouldBe(new[] { "d1" }); + } /// Verifies that empty blob returns empty list. [Fact] public void Empty_blob_returns_empty_list()