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