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 6e2d199c..c64a2c69 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DeploymentArtifact.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DeploymentArtifact.cs
@@ -73,6 +73,14 @@ public sealed record DataPlaneAuthorityProjection(
/// The node's ClusterId when is ScopeTo; otherwise null.
public readonly record struct ClusterScope(ClusterFilterMode Mode, string? ClusterId);
+///
+/// Static, blob-in / result-out facade over . Every entry point
+/// here parses the blob, answers ONE question, and throws the parsed document away — so an apply that
+/// asks several questions of the same bytes pays for several full builds.
+/// Prefer when more than one answer is needed from the same artifact; these wrappers
+/// exist so the one-shot call sites (and every existing test) keep their exact shape and their lenient
+/// empty/malformed contract.
+///
public static class DeploymentArtifact
{
private static readonly JsonSerializerOptions JsonOptions = new()
@@ -80,6 +88,19 @@ public static class DeploymentArtifact
PropertyNameCaseInsensitive = true,
};
+ ///
+ /// Parse ONCE into a reusable , so
+ /// every consumer of a single apply reads the same instead of rebuilding
+ /// it per question. Dispose it when the apply completes.
+ ///
+ /// The deployment artifact bytes. Not copied — the returned artifact reads directly
+ /// from this array, which must not be mutated for its lifetime (artifact blobs never are: they are
+ /// deployment-immutable bytes read from SQL, the LocalDb cache, or a config fetch).
+ /// The parsed artifact. The caller owns it and must dispose it.
+ /// The blob is not valid JSON. Unlike the static wrappers — which own
+ /// the lenient "malformed ⇒ empty result" contract — opening is explicit about unusable bytes.
+ public static ParsedDeploymentArtifact Open(byte[] blob) => new(JsonDocument.Parse(blob));
+
///
/// Parse a deployment artifact blob into the list of driver-instance specs to spawn.
/// Empty / malformed blobs return an empty list — callers log + treat as "no drivers".
@@ -89,32 +110,10 @@ public static class DeploymentArtifact
public static IReadOnlyList ParseDriverInstances(ReadOnlySpan blob)
{
if (blob.IsEmpty) return Array.Empty();
-
try
{
- using var doc = JsonDocument.Parse(blob.ToArray());
- var root = doc.RootElement;
- if (!root.TryGetProperty("DriverInstances", out var arr)
- || arr.ValueKind != JsonValueKind.Array)
- {
- return Array.Empty();
- }
-
- // v3: each spec's DriverConfig is the MERGED config the driver factory binds from —
- // DriverConfig (protocol/channel) + per-Device DeviceConfig (endpoint) + the driver's authored
- // raw tags (RawTags array, keyed by RawPath) — folded once here via DriverDeviceConfigMerger so
- // both spawn (TryCreate) and redeploy (ApplyDelta) hand the driver the same shape.
- var rawTagsByDriver = BuildRawTagsByDriver(root);
- var deviceRowsByDriver = BuildDeviceRowsByDriver(root);
-
- var result = new List(arr.GetArrayLength());
- foreach (var el in arr.EnumerateArray())
- {
- if (el.ValueKind != JsonValueKind.Object) continue;
- var spec = TryReadSpec(el, rawTagsByDriver, deviceRowsByDriver);
- if (spec is not null) result.Add(spec);
- }
- return result;
+ using var parsed = Open(blob.ToArray());
+ return parsed.ParseDriverInstances();
}
catch (JsonException)
{
@@ -122,6 +121,195 @@ 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. a "host:port" string) 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 parsed = Open(blob.ToArray());
+ return parsed.ResolveClusterScope(nodeId);
+ }
+ 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. a "host:port" string) used to resolve cluster scope.
+ /// The driver-instance specs this node should spawn.
+ public static IReadOnlyList ParseDriverInstances(ReadOnlySpan blob, string nodeId)
+ {
+ if (blob.IsEmpty) return Array.Empty();
+ try
+ {
+ using var parsed = Open(blob.ToArray());
+ return parsed.ParseDriverInstances(nodeId);
+ }
+ catch (JsonException)
+ {
+ return Array.Empty();
+ }
+ }
+
+ ///
+ /// Parse the data-plane authorization inputs a node needs to authenticate OPC UA sessions:
+ /// the ApiKeys the artifact carries and the NodeAcls granting their principals
+ /// permissions (#520, stage 4). See
+ /// for the scoping + leniency contract.
+ ///
+ /// The deployment artifact blob to parse.
+ /// The node's identity, used to resolve cluster scope for the grants.
+ /// The node's cluster (null when single-cluster), its keys, and its cluster's grants.
+ public static DataPlaneAuthorityProjection ParseDataPlaneAuthority(ReadOnlySpan blob, string nodeId)
+ {
+ if (blob.IsEmpty) return DataPlaneAuthorityProjection.Empty;
+ try
+ {
+ using var parsed = Open(blob.ToArray());
+ return parsed.ParseDataPlaneAuthority(nodeId);
+ }
+ catch (JsonException)
+ {
+ return DataPlaneAuthorityProjection.Empty;
+ }
+ }
+
+ ///
+ /// Parse the artifact into the projected used by
+ /// AddressSpacePlanner + AddressSpaceApplier. Returns an empty composition for empty/
+ /// malformed blobs so callers can treat parse failure as a no-op deploy.
+ ///
+ /// The deployment artifact blob to parse.
+ /// The projected , or an empty composition for empty/malformed blobs.
+ public static AddressSpaceComposition ParseComposition(ReadOnlySpan blob)
+ {
+ if (blob.IsEmpty) return ParsedDeploymentArtifact.Empty();
+ try
+ {
+ using var parsed = Open(blob.ToArray());
+ return parsed.ParseComposition();
+ }
+ catch (JsonException)
+ {
+ return ParsedDeploymentArtifact.Empty();
+ }
+ }
+
+ /// Cluster-scoped overload: the address-space composition a node should materialise given
+ /// its NodeId. See .
+ /// 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 AddressSpaceComposition ParseComposition(
+ ReadOnlySpan blob, string nodeId, Action? onInconsistency = null)
+ {
+ if (blob.IsEmpty) return ParsedDeploymentArtifact.Empty();
+ try
+ {
+ using var parsed = Open(blob.ToArray());
+ return parsed.ParseComposition(nodeId, onInconsistency);
+ }
+ catch (JsonException)
+ {
+ return ParsedDeploymentArtifact.Empty();
+ }
+ }
+}
+
+///
+/// A deployment artifact parsed ONCE — one serving every consumer of a single
+/// apply (driver specs, cluster scope, data-plane authority, address-space composition), instead of the
+/// full copy-and-reparse each static entry point on pays per call.
+///
+///
+///
+/// Open it with at the top of an apply and dispose it when the
+/// apply completes. Everything it returns is fully materialised (records, lists, strings) and holds
+/// no reference back into the document, so results stay valid after disposal.
+///
+///
+/// Not thread-safe: the per-node memo is an unsynchronised
+/// dictionary. An apply resolves its consumers sequentially, which is the only intended use.
+///
+///
+public sealed class ParsedDeploymentArtifact : IDisposable
+{
+ private readonly JsonDocument _document;
+
+ // Every consumer of one apply resolves the SAME node id, so this memo turns N identical scope scans
+ // into one. Keyed ordinally: the scan itself matches node ids case-insensitively, so a differently
+ // cased key simply misses the memo and re-scans to the same answer — never a wrong one.
+ private readonly Dictionary _scopeByNodeId = new(StringComparer.Ordinal);
+
+ private bool _disposed;
+
+ /// Takes ownership of an already-parsed artifact document. Use
+ /// ; this exists so the document is parsed in exactly one place.
+ /// The parsed artifact document, disposed with this instance.
+ internal ParsedDeploymentArtifact(JsonDocument document) => _document = document;
+
+ /// The artifact's root JSON object.
+ public JsonElement Root => _document.RootElement;
+
+ /// Releases the underlying . Idempotent.
+ public void Dispose()
+ {
+ if (_disposed) return;
+ _disposed = true;
+ _document.Dispose();
+ }
+
+ ///
+ /// The list of driver-instance specs to spawn, from the artifact's DriverInstances array.
+ /// An artifact with no such array yields an empty list.
+ ///
+ /// The parsed driver-instance specs.
+ public IReadOnlyList ParseDriverInstances()
+ {
+ var root = Root;
+ if (!root.TryGetProperty("DriverInstances", out var arr)
+ || arr.ValueKind != JsonValueKind.Array)
+ {
+ return Array.Empty();
+ }
+
+ // v3: each spec's DriverConfig is the MERGED config the driver factory binds from —
+ // DriverConfig (protocol/channel) + per-Device DeviceConfig (endpoint) + the driver's authored
+ // raw tags (RawTags array, keyed by RawPath) — folded once here via DriverDeviceConfigMerger so
+ // both spawn (TryCreate) and redeploy (ApplyDelta) hand the driver the same shape.
+ var rawTagsByDriver = BuildRawTagsByDriver(root);
+ var deviceRowsByDriver = BuildDeviceRowsByDriver(root);
+
+ var result = new List(arr.GetArrayLength());
+ foreach (var el in arr.EnumerateArray())
+ {
+ if (el.ValueKind != JsonValueKind.Object) continue;
+ var spec = TryReadSpec(el, rawTagsByDriver, deviceRowsByDriver);
+ if (spec is not null) result.Add(spec);
+ }
+ return result;
+ }
+
/// Flatten the artifact's raw topology (RawFolders / DriverInstances / Devices / TagGroups /
/// Tags) into each driver's authored list, keyed by DriverInstanceId. Each
/// tag's RawPath is computed via the shared (the single identity authority),
@@ -499,71 +687,67 @@ public static class DeploymentArtifact
el.TryGetProperty(property, out var p) && p.ValueKind == JsonValueKind.String ? p.GetString() : null;
///
- /// Resolve how a node should scope a deployment artifact to its own ClusterId. Single-cluster
+ /// Resolve how a node should scope this 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).
+ /// ClusterId; a missing row resolves to .
+ /// Memoized per node id — every consumer of one apply asks about the same node.
///
- /// The deployment artifact blob to inspect.
/// The node's identity (e.g. a "host:port" string) to match against Nodes.
/// The resolved decision for this node.
- public static ClusterScope ResolveClusterScope(ReadOnlySpan blob, string nodeId)
+ public ClusterScope ResolveClusterScope(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);
+ if (_scopeByNodeId.TryGetValue(nodeId, out var cached)) return cached;
+ var resolved = ResolveClusterScopeCore(nodeId);
+ _scopeByNodeId[nodeId] = resolved;
+ return resolved;
+ }
- 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.OrdinalIgnoreCase)) 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)
+ private ClusterScope ResolveClusterScopeCore(string nodeId)
+ {
+ var root = Root;
+ 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)
{
- return new ClusterScope(ClusterFilterMode.None, null);
+ 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.OrdinalIgnoreCase)) 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);
}
///
- /// 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 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. a "host:port" string) used to resolve cluster scope.
/// The driver-instance specs this node should spawn.
- public static IReadOnlyList ParseDriverInstances(ReadOnlySpan blob, string nodeId)
+ public IReadOnlyList ParseDriverInstances(string nodeId)
{
- var scope = ResolveClusterScope(blob, nodeId);
+ var scope = ResolveClusterScope(nodeId);
if (scope.Mode == ClusterFilterMode.Suppress) return Array.Empty();
- var all = ParseDriverInstances(blob);
+ var all = ParseDriverInstances();
return scope.Mode == ClusterFilterMode.ScopeTo
? all.Where(s => string.Equals(s.ClusterId, scope.ClusterId, StringComparison.OrdinalIgnoreCase)).ToArray()
: all;
}
///
- /// Parse the data-plane authorization inputs a node needs to authenticate OPC UA sessions:
+ /// The data-plane authorization inputs a node needs to authenticate OPC UA sessions:
/// the ApiKeys the artifact carries and the NodeAcls granting their principals
/// permissions (#520, stage 4).
///
@@ -573,60 +757,48 @@ public static class DeploymentArtifact
/// it with no ConfigDb, so without this it denies every UserName token.
///
///
- /// Grants are scoped to the node's own cluster (); keys are
- /// not, because ApiKey carries no ClusterId — a key is a fleet-wide credential and it
+ /// Grants are scoped to the node's own cluster (); keys
+ /// are not, because ApiKey carries no ClusterId — a key is a fleet-wide credential and it
/// is the ACL rows that bind it to a cluster's nodes. A
/// scope — the node's row is absent from a multi-cluster artifact — yields NO grants, matching
/// the driver-spawn path's reading that an unlisted node was not deployed to.
///
///
- /// Lenient like every other parser here: an empty or malformed blob yields empty lists. The
- /// caller must NOT treat that as "no keys are valid" and install it — see issue #485; the
- /// apply paths only reach this with bytes that already passed their own emptiness guard.
+ /// Lenient like every other parser here: an artifact carrying neither section yields empty
+ /// lists. The caller must NOT treat that as "no keys are valid" and install it — see issue
+ /// #485; the apply paths only reach this with bytes that already passed their own guard.
///
///
- /// The deployment artifact blob to parse.
/// The node's identity, used to resolve cluster scope for the grants.
/// The node's cluster (null when single-cluster), its keys, and its cluster's grants.
- public static DataPlaneAuthorityProjection ParseDataPlaneAuthority(ReadOnlySpan blob, string nodeId)
+ public DataPlaneAuthorityProjection ParseDataPlaneAuthority(string nodeId)
{
- if (blob.IsEmpty) return DataPlaneAuthorityProjection.Empty;
+ var scope = ResolveClusterScope(nodeId);
+ var root = Root;
- var scope = ResolveClusterScope(blob, nodeId);
-
- try
+ var keys = new List();
+ foreach (var el in EnumerateArray(root, "ApiKeys"))
{
- using var doc = JsonDocument.Parse(blob.ToArray());
- var root = doc.RootElement;
-
- var keys = new List();
- foreach (var el in EnumerateArray(root, "ApiKeys"))
- {
- var key = TryReadApiKey(el);
- if (key is not null) keys.Add(key);
- }
-
- var grants = new List();
- foreach (var el in EnumerateArray(root, "NodeAcls"))
- {
- var acl = TryReadNodeAcl(el);
- if (acl is null) continue;
- if (scope.Mode == ClusterFilterMode.Suppress) continue;
- if (scope.Mode == ClusterFilterMode.ScopeTo
- && !string.Equals(acl.ClusterId, scope.ClusterId, StringComparison.OrdinalIgnoreCase))
- {
- continue;
- }
- grants.Add(acl);
- }
-
- return new DataPlaneAuthorityProjection(
- scope.ClusterId, keys, grants, BuildAclScopePaths(root, grants));
+ var key = TryReadApiKey(el);
+ if (key is not null) keys.Add(key);
}
- catch (JsonException)
+
+ var grants = new List();
+ foreach (var el in EnumerateArray(root, "NodeAcls"))
{
- return DataPlaneAuthorityProjection.Empty;
+ var acl = TryReadNodeAcl(el);
+ if (acl is null) continue;
+ if (scope.Mode == ClusterFilterMode.Suppress) continue;
+ if (scope.Mode == ClusterFilterMode.ScopeTo
+ && !string.Equals(acl.ClusterId, scope.ClusterId, StringComparison.OrdinalIgnoreCase))
+ {
+ continue;
+ }
+ grants.Add(acl);
}
+
+ return new DataPlaneAuthorityProjection(
+ scope.ClusterId, keys, grants, BuildAclScopePaths(root, grants));
}
///
@@ -880,25 +1052,19 @@ public static class DeploymentArtifact
///
/// Parse the artifact into the projected used by
- /// AddressSpacePlanner + AddressSpaceApplier. Returns an empty composition for empty/
- /// malformed blobs so callers can treat parse failure as a no-op deploy.
+ /// AddressSpacePlanner + AddressSpaceApplier. An artifact carrying none of the
+ /// projected arrays yields an empty composition, which callers treat as a no-op deploy.
///
/// The artifact JSON is produced by ConfigComposer.SnapshotAndFlattenAsync in the
/// ControlPlane — its Pascal-case property names match the EF entities. We only need a
/// subset of fields per entity class to drive the address-space rebuild on driver-role
/// nodes.
///
- /// The deployment artifact blob to parse.
- /// The projected , or an empty composition for empty/malformed blobs.
- public static AddressSpaceComposition ParseComposition(ReadOnlySpan blob)
+ /// The projected .
+ public AddressSpaceComposition ParseComposition()
{
- if (blob.IsEmpty) return Empty();
-
- try
+ var root = Root;
{
- using var doc = JsonDocument.Parse(blob.ToArray());
- var root = doc.RootElement;
-
var areas = ReadArray(root, "UnsAreas", ReadAreaProjection);
var lines = ReadArray(root, "UnsLines", ReadLineProjection);
var equipment = ReadArray(root, "Equipment", ReadEquipmentNode);
@@ -932,14 +1098,10 @@ public static class DeploymentArtifact
UnsReferenceVariables = unsReferenceVariables,
};
}
- catch (JsonException)
- {
- return Empty();
- }
}
/// 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 ).
/// Equipment attribution is dual-path: driver-bound equipment (non-null DriverInstanceId) is kept when
/// its driver is in-cluster; driver-less equipment (null DriverInstanceId) is kept when its UNS line's
/// area is in-cluster.
@@ -948,19 +1110,17 @@ public static class DeploymentArtifact
/// that violates the same-cluster invariant 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 AddressSpaceComposition ParseComposition(
- ReadOnlySpan blob, string nodeId, Action? onInconsistency = null)
+ public AddressSpaceComposition ParseComposition(string nodeId, Action? onInconsistency = null)
{
- var scope = ResolveClusterScope(blob, nodeId);
- if (scope.Mode == ClusterFilterMode.None) return ParseComposition(blob);
+ var scope = ResolveClusterScope(nodeId);
+ if (scope.Mode == ClusterFilterMode.None) return ParseComposition();
if (scope.Mode == ClusterFilterMode.Suppress) return Empty();
- var full = ParseComposition(blob);
- var sets = BuildClusterSets(blob, scope.ClusterId!);
+ var full = ParseComposition();
+ var sets = BuildClusterSets(Root, 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();
@@ -1012,18 +1172,16 @@ public static class DeploymentArtifact
/// that directly carry the ClusterId, plus in-cluster EquipmentIds — driver-bound equipment attributed
/// by its driver's cluster, and driver-less equipment (null DriverInstanceId) attributed by its UNS
/// line's area cluster.
- /// The deployment artifact blob.
+ /// The artifact root element — the SAME document every other consumer reads, rather
+ /// than a second parse of the same bytes (this used to re-parse the blob on every scoped compose).
/// The node's ClusterId to scope to.
- /// The resolved in-cluster id sets (empty on parse failure => empty composition).
- private static ClusterSets BuildClusterSets(ReadOnlySpan blob, string clusterId)
+ /// The resolved in-cluster id sets.
+ private static ClusterSets BuildClusterSets(JsonElement root, string clusterId)
{
var driverIds = new HashSet(StringComparer.Ordinal);
var areaIds = new HashSet(StringComparer.Ordinal);
var equipmentIds = new HashSet(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);
// Map each UnsLine to its owning UnsArea so driver-less equipment can be attributed via
@@ -1055,7 +1213,6 @@ public static class DeploymentArtifact
}
}
}
- catch (JsonException) { /* empty sets => nothing matches => empty composition */ }
return new ClusterSets(driverIds, areaIds, equipmentIds);
}
@@ -1080,7 +1237,9 @@ public static class DeploymentArtifact
}
}
- private static AddressSpaceComposition Empty() => new(
+ /// The composition an absent / empty / unparseable artifact projects to: nothing at all.
+ /// An empty composition.
+ internal static AddressSpaceComposition Empty() => new(
Array.Empty(),
Array.Empty(),
Array.Empty(),
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/ParsedDeploymentArtifactTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/ParsedDeploymentArtifactTests.cs
new file mode 100644
index 00000000..11a101a7
--- /dev/null
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/ParsedDeploymentArtifactTests.cs
@@ -0,0 +1,379 @@
+using System.Text;
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.OtOpcUa.Core.Authorization;
+using ZB.MOM.WW.OtOpcUa.OpcUaServer;
+using ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
+
+namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers;
+
+///
+/// Parity coverage for / —
+/// the "parse the artifact JSON once per apply" seam. Every public static entry point on
+/// is now a thin wrapper over the instance method of the same name,
+/// so the ONLY thing that must be proved is that the two produce identical output for the same bytes.
+///
+///
+///
+/// These are not "does the parser work" tests — that is what the eight existing
+/// DeploymentArtifact*Tests suites are for, and they now exercise the instance path through
+/// the wrappers. These pin the refactor itself: same bytes ⇒ same output through either door.
+///
+///
+/// Record equality is NOT usable for the composition/projection results: their members are
+/// IReadOnlyList/IReadOnlyDictionary, which records compare by REFERENCE, so two
+/// separately-produced-but-identical results are never ==. Every assertion here therefore
+/// compares element-wise (the element types are records with scalar members, which do compare by
+/// value).
+///
+///
+public sealed class ParsedDeploymentArtifactTests
+{
+ private const string NodeC1 = "c1-1:4053";
+ private const string NodeC2 = "c2-1:4053";
+ private const string NodeUnlisted = "stranger:4053";
+
+ ///
+ /// A deliberately rich MULTI-CLUSTER artifact: two clusters + their node rows, the full raw chain
+ /// (folder → driver → device → tag group → tags, including a historized tag, an array tag and a
+ /// native-alarm tag), the UNS chain (area → line → equipment) with a tag reference carrying a
+ /// display-name override, scripts joined by both a VirtualTag and a ScriptedAlarm (with a
+ /// {{equip}} reference so the equipment-reference map is genuinely exercised), plus ApiKeys
+ /// and NodeAcls so the data-plane projection has something to scope.
+ ///
+ private const string Artifact = """
+ {
+ "Clusters": [{"ClusterId":"C1"},{"ClusterId":"C2"}],
+ "Nodes": [
+ {"NodeId":"c1-1:4053","ClusterId":"C1"},
+ {"NodeId":"c2-1:4053","ClusterId":"C2"}
+ ],
+ "RawFolders": [
+ {"RawFolderId":"RF-plant","ClusterId":"C1","ParentRawFolderId":null,"Name":"Plant"},
+ {"RawFolderId":"RF-line1","ClusterId":"C1","ParentRawFolderId":"RF-plant","Name":"Line1"},
+ {"RawFolderId":"RF-c2","ClusterId":"C2","ParentRawFolderId":null,"Name":"Site2"}
+ ],
+ "DriverInstances": [
+ {"DriverInstanceRowId":"11111111-1111-1111-1111-111111111111","DriverInstanceId":"DI-modbus","ClusterId":"C1","RawFolderId":"RF-line1","Name":"ModbusDrv","DriverType":"Modbus","Enabled":true,"DriverConfig":"{\"unitId\":1}","ResilienceConfig":"{\"bulkheadMaxConcurrent\":16}"},
+ {"DriverInstanceRowId":"22222222-2222-2222-2222-222222222222","DriverInstanceId":"DI-s7","ClusterId":"C2","RawFolderId":"RF-c2","Name":"S7Drv","DriverType":"S7","Enabled":true,"DriverConfig":"{}"}
+ ],
+ "Devices": [
+ {"DeviceId":"DV-1","DriverInstanceId":"DI-modbus","Name":"dev1","DeviceConfig":"{\"endpoint\":\"10.0.0.1:502\"}"},
+ {"DeviceId":"DV-2","DriverInstanceId":"DI-s7","Name":"dev2","DeviceConfig":"{}"}
+ ],
+ "TagGroups": [{"TagGroupId":"TG-fast","DeviceId":"DV-1","ParentTagGroupId":null,"Name":"Fast"}],
+ "Tags": [
+ {"TagId":"TAG-speed","DeviceId":"DV-1","TagGroupId":"TG-fast","Name":"Speed","DataType":"Float","AccessLevel":1,"WriteIdempotent":true,"TagConfig":"{\"isHistorized\":true,\"historianTagname\":\"WW.Speed\"}"},
+ {"TagId":"TAG-run","DeviceId":"DV-1","TagGroupId":null,"Name":"Run","DataType":"Boolean","AccessLevel":0,"TagConfig":"{}"},
+ {"TagId":"TAG-profile","DeviceId":"DV-1","TagGroupId":"TG-fast","Name":"Profile","DataType":"Float","AccessLevel":0,"TagConfig":"{\"isArray\":true,\"arrayLength\":16}"},
+ {"TagId":"TAG-temphi","DeviceId":"DV-1","TagGroupId":null,"Name":"TempHi","DataType":"Boolean","AccessLevel":1,"TagConfig":"{\"alarm\":{\"alarmType\":\"OffNormalAlarm\",\"severity\":700}}"},
+ {"TagId":"TAG-c2","DeviceId":"DV-2","TagGroupId":null,"Name":"Pressure","DataType":"Float","AccessLevel":0,"TagConfig":"{}"}
+ ],
+ "UnsAreas": [
+ {"UnsAreaId":"UA-w","ClusterId":"C1","Name":"West"},
+ {"UnsAreaId":"UA-e","ClusterId":"C2","Name":"East"}
+ ],
+ "UnsLines": [
+ {"UnsLineId":"UL-1","UnsAreaId":"UA-w","Name":"L1"},
+ {"UnsLineId":"UL-2","UnsAreaId":"UA-e","Name":"L2"}
+ ],
+ "Equipment": [
+ {"EquipmentId":"EQ-mixer","UnsLineId":"UL-1","Name":"Mixer","MachineCode":"MX1"},
+ {"EquipmentId":"EQ-press","UnsLineId":"UL-2","Name":"Press","MachineCode":"PR1"}
+ ],
+ "UnsTagReferences": [
+ {"UnsTagReferenceId":"REF-1","EquipmentId":"EQ-mixer","TagId":"TAG-speed","DisplayNameOverride":"MixerSpeed"},
+ {"UnsTagReferenceId":"REF-2","EquipmentId":"EQ-mixer","TagId":"TAG-run","DisplayNameOverride":null}
+ ],
+ "Scripts": [
+ {"ScriptId":"SCR-vt","SourceCode":"return ctx.GetTag(\"{{equip}}/MixerSpeed\") * 2;"},
+ {"ScriptId":"SCR-alarm","SourceCode":"return ctx.GetTag(\"{{equip}}/Run\") == false;"}
+ ],
+ "VirtualTags": [
+ {"VirtualTagId":"VT-1","EquipmentId":"EQ-mixer","Name":"DoubleSpeed","DataType":"Float","ScriptId":"SCR-vt","Historize":true}
+ ],
+ "ScriptedAlarms": [
+ {"ScriptedAlarmId":"SA-1","EquipmentId":"EQ-mixer","Name":"Stopped","AlarmType":"OffNormalAlarm","Severity":500,"MessageTemplate":"Mixer stopped","PredicateScriptId":"SCR-alarm","HistorizeToAveva":true,"Retain":true,"Enabled":true}
+ ],
+ "ApiKeys": [
+ {"ApiKeyId":"k1","Name":"key-one","SecretHash":"hash-one","Principal":"p","Enabled":true,"CreatedUtc":"2026-01-01T00:00:00","ExpiresUtc":"2030-06-15T12:00:00"},
+ {"ApiKeyId":"k2","Name":"key-two","SecretHash":"hash-two","Principal":"anonymous","Enabled":false,"CreatedUtc":"2026-01-01T00:00:00"}
+ ],
+ "NodeAcls": [
+ {"NodeAclId":"acl-dev","ClusterId":"C1","Principal":"p","ScopeKind":9,"ScopeId":"DV-1","PermissionFlags":2},
+ {"NodeAclId":"acl-eq","ClusterId":"C1","Principal":"p","ScopeKind":4,"ScopeId":"EQ-mixer","PermissionFlags":16},
+ {"NodeAclId":"acl-c2","ClusterId":"C2","Principal":"p","ScopeKind":4,"ScopeId":"EQ-press","PermissionFlags":16}
+ ]
+ }
+ """;
+
+ private static byte[] Blob => Encoding.UTF8.GetBytes(Artifact);
+
+ [Theory]
+ [InlineData(NodeC1)]
+ [InlineData(NodeC2)]
+ [InlineData(NodeUnlisted)]
+ public void ResolveClusterScope_is_identical_through_the_parsed_artifact(string nodeId)
+ {
+ var blob = Blob;
+ using var parsed = DeploymentArtifact.Open(blob);
+
+ parsed.ResolveClusterScope(nodeId).ShouldBe(DeploymentArtifact.ResolveClusterScope(blob, nodeId));
+ }
+
+ [Fact]
+ public void The_scope_fixture_actually_distinguishes_its_nodes()
+ {
+ // Falsifiability control for the parity theory above: if every node resolved to the same scope,
+ // a memoization cache keyed on nothing at all would still pass it.
+ var blob = Blob;
+
+ DeploymentArtifact.ResolveClusterScope(blob, NodeC1)
+ .ShouldBe(new ClusterScope(ClusterFilterMode.ScopeTo, "C1"));
+ DeploymentArtifact.ResolveClusterScope(blob, NodeC2)
+ .ShouldBe(new ClusterScope(ClusterFilterMode.ScopeTo, "C2"));
+ DeploymentArtifact.ResolveClusterScope(blob, NodeUnlisted)
+ .ShouldBe(new ClusterScope(ClusterFilterMode.Suppress, null));
+ }
+
+ [Fact]
+ public void ParseDriverInstances_unscoped_is_identical_through_the_parsed_artifact()
+ {
+ var blob = Blob;
+ using var parsed = DeploymentArtifact.Open(blob);
+
+ var expected = DeploymentArtifact.ParseDriverInstances(blob);
+ expected.Count.ShouldBe(2); // sanity: an empty-vs-empty comparison proves nothing
+
+ parsed.ParseDriverInstances().ShouldBe(expected);
+ }
+
+ [Theory]
+ [InlineData(NodeC1)]
+ [InlineData(NodeC2)]
+ [InlineData(NodeUnlisted)]
+ public void ParseDriverInstances_scoped_is_identical_through_the_parsed_artifact(string nodeId)
+ {
+ var blob = Blob;
+ using var parsed = DeploymentArtifact.Open(blob);
+
+ parsed.ParseDriverInstances(nodeId).ShouldBe(DeploymentArtifact.ParseDriverInstances(blob, nodeId));
+ }
+
+ [Fact]
+ public void The_scoped_driver_fixture_actually_filters()
+ {
+ var blob = Blob;
+
+ DeploymentArtifact.ParseDriverInstances(blob, NodeC1)
+ .Select(s => s.DriverInstanceId).ShouldBe(new[] { "DI-modbus" });
+ DeploymentArtifact.ParseDriverInstances(blob, NodeC2)
+ .Select(s => s.DriverInstanceId).ShouldBe(new[] { "DI-s7" });
+ DeploymentArtifact.ParseDriverInstances(blob, NodeUnlisted).ShouldBeEmpty();
+ }
+
+ [Theory]
+ [InlineData(NodeC1)]
+ [InlineData(NodeC2)]
+ [InlineData(NodeUnlisted)]
+ public void ParseDataPlaneAuthority_is_identical_through_the_parsed_artifact(string nodeId)
+ {
+ var blob = Blob;
+ using var parsed = DeploymentArtifact.Open(blob);
+
+ AssertSameAuthority(parsed.ParseDataPlaneAuthority(nodeId),
+ DeploymentArtifact.ParseDataPlaneAuthority(blob, nodeId));
+ }
+
+ [Fact]
+ public void The_authority_fixture_carries_keys_grants_and_resolved_scope_paths()
+ {
+ // Sanity on the parity theory above: it compares three collections, and three empties compare equal.
+ var projection = DeploymentArtifact.ParseDataPlaneAuthority(Blob, NodeC1);
+
+ projection.ClusterId.ShouldBe("C1");
+ projection.Keys.Select(k => k.ApiKeyId).ShouldBe(new[] { "k1", "k2" });
+ projection.Grants.Select(g => g.NodeAclId).ShouldBe(new[] { "acl-dev", "acl-eq" });
+ projection.ScopePaths.ShouldNotBeEmpty();
+ }
+
+ [Fact]
+ public void ParseComposition_unscoped_is_identical_through_the_parsed_artifact()
+ {
+ var blob = Blob;
+ using var parsed = DeploymentArtifact.Open(blob);
+
+ var expected = DeploymentArtifact.ParseComposition(blob);
+ expected.RawTags.ShouldNotBeEmpty();
+ expected.UnsReferenceVariables.ShouldNotBeEmpty();
+ expected.EquipmentVirtualTags.ShouldNotBeEmpty();
+ expected.EquipmentScriptedAlarms.ShouldNotBeEmpty();
+
+ AssertSameComposition(parsed.ParseComposition(), expected);
+ }
+
+ [Theory]
+ [InlineData(NodeC1)]
+ [InlineData(NodeC2)]
+ [InlineData(NodeUnlisted)]
+ public void ParseComposition_scoped_is_identical_through_the_parsed_artifact(string nodeId)
+ {
+ var blob = Blob;
+ using var parsed = DeploymentArtifact.Open(blob);
+
+ AssertSameComposition(parsed.ParseComposition(nodeId), DeploymentArtifact.ParseComposition(blob, nodeId));
+ }
+
+ [Fact]
+ public void The_scoped_composition_fixture_actually_filters()
+ {
+ // BuildClusterSets is the piece this refactor de-duplicated (it used to re-parse the blob), so the
+ // scoped composition has to keep filtering rather than degrade to "everything" or "nothing".
+ var blob = Blob;
+
+ var c1 = DeploymentArtifact.ParseComposition(blob, NodeC1);
+ c1.UnsAreas.Select(a => a.UnsAreaId).ShouldBe(new[] { "UA-w" });
+ c1.EquipmentNodes.Select(e => e.EquipmentId).ShouldBe(new[] { "EQ-mixer" });
+ c1.RawTags.Select(t => t.DriverInstanceId).Distinct().ShouldBe(new[] { "DI-modbus" });
+ c1.UnsReferenceVariables.Select(v => v.EquipmentId).Distinct().ShouldBe(new[] { "EQ-mixer" });
+
+ var c2 = DeploymentArtifact.ParseComposition(blob, NodeC2);
+ c2.UnsAreas.Select(a => a.UnsAreaId).ShouldBe(new[] { "UA-e" });
+ c2.EquipmentNodes.Select(e => e.EquipmentId).ShouldBe(new[] { "EQ-press" });
+
+ DeploymentArtifact.ParseComposition(blob, NodeUnlisted).EquipmentNodes.ShouldBeEmpty();
+ }
+
+ [Fact]
+ public void The_scoped_composition_inconsistency_callback_fires_identically_through_both_doors()
+ {
+ var blob = Blob;
+ using var parsed = DeploymentArtifact.Open(blob);
+
+ var viaStatic = new List();
+ DeploymentArtifact.ParseComposition(blob, NodeC1, viaStatic.Add);
+ var viaInstance = new List();
+ parsed.ParseComposition(NodeC1, viaInstance.Add);
+
+ viaInstance.ShouldBe(viaStatic);
+ }
+
+ [Fact]
+ public void A_second_call_on_the_same_parsed_artifact_returns_equal_results()
+ {
+ // Memoization sanity: ResolveClusterScope is cached per node id, and the cache must not leak the
+ // first caller's answer to a different node — nor stale the same caller's answer.
+ using var parsed = DeploymentArtifact.Open(Blob);
+
+ var firstC1 = parsed.ResolveClusterScope(NodeC1);
+ var firstC2 = parsed.ResolveClusterScope(NodeC2);
+ parsed.ResolveClusterScope(NodeC1).ShouldBe(firstC1);
+ parsed.ResolveClusterScope(NodeC2).ShouldBe(firstC2);
+ firstC1.ShouldNotBe(firstC2);
+
+ parsed.ParseDriverInstances().ShouldBe(parsed.ParseDriverInstances());
+ parsed.ParseDriverInstances(NodeC1).ShouldBe(parsed.ParseDriverInstances(NodeC1));
+ AssertSameAuthority(parsed.ParseDataPlaneAuthority(NodeC1), parsed.ParseDataPlaneAuthority(NodeC1));
+ AssertSameComposition(parsed.ParseComposition(), parsed.ParseComposition());
+ AssertSameComposition(parsed.ParseComposition(NodeC1), parsed.ParseComposition(NodeC1));
+ }
+
+ [Fact]
+ public void Results_survive_disposing_the_parsed_artifact()
+ {
+ // Everything returned is fully materialised — no JsonElement is retained, so a caller that disposes
+ // the artifact the moment the apply's parsing is done cannot be handed a use-after-dispose.
+ AddressSpaceComposition composition;
+ DataPlaneAuthorityProjection authority;
+ IReadOnlyList specs;
+
+ using (var parsed = DeploymentArtifact.Open(Blob))
+ {
+ composition = parsed.ParseComposition(NodeC1);
+ authority = parsed.ParseDataPlaneAuthority(NodeC1);
+ specs = parsed.ParseDriverInstances(NodeC1);
+ }
+
+ // Touch every collection AFTER disposal — a retained JsonElement would throw ObjectDisposedException.
+ composition.RawTags.Select(t => t.NodeId).ShouldNotBeEmpty();
+ composition.UnsReferenceVariables.Select(v => v.NodeId).ShouldNotBeEmpty();
+ composition.EquipmentVirtualTags.Select(v => v.Expression).ShouldNotBeEmpty();
+ composition.EquipmentScriptedAlarms.Select(a => a.PredicateSource).ShouldNotBeEmpty();
+ authority.Keys.Select(k => k.SecretHash).ShouldNotBeEmpty();
+ authority.ScopePaths.SelectMany(kv => kv.Value).Select(p => p.Segments.Count).ShouldNotBeEmpty();
+ specs.Select(s => s.DriverConfig).ShouldNotBeEmpty();
+ }
+
+ [Fact]
+ public void Disposing_twice_is_harmless()
+ {
+ var parsed = DeploymentArtifact.Open(Blob);
+ parsed.Dispose();
+ Should.NotThrow(() => parsed.Dispose());
+ }
+
+ [Fact]
+ public void A_malformed_blob_still_degrades_leniently_through_every_static_wrapper()
+ {
+ // The statics own the lenient contract (empty list / empty composition / None scope) — Open() itself
+ // throws, because a caller that opens an artifact wants to know the bytes were unusable.
+ var bad = Encoding.UTF8.GetBytes("{ this is not json");
+
+ DeploymentArtifact.ParseDriverInstances(bad).ShouldBeEmpty();
+ DeploymentArtifact.ParseDriverInstances(bad, NodeC1).ShouldBeEmpty();
+ DeploymentArtifact.ResolveClusterScope(bad, NodeC1).Mode.ShouldBe(ClusterFilterMode.None);
+ DeploymentArtifact.ParseDataPlaneAuthority(bad, NodeC1).Keys.ShouldBeEmpty();
+ DeploymentArtifact.ParseComposition(bad).EquipmentNodes.ShouldBeEmpty();
+ DeploymentArtifact.ParseComposition(bad, NodeC1).EquipmentNodes.ShouldBeEmpty();
+ }
+
+ [Fact]
+ public void An_empty_blob_still_degrades_leniently_through_every_static_wrapper()
+ {
+ var empty = Array.Empty();
+
+ DeploymentArtifact.ParseDriverInstances(empty).ShouldBeEmpty();
+ DeploymentArtifact.ParseDriverInstances(empty, NodeC1).ShouldBeEmpty();
+ DeploymentArtifact.ResolveClusterScope(empty, NodeC1).Mode.ShouldBe(ClusterFilterMode.None);
+ DeploymentArtifact.ParseDataPlaneAuthority(empty, NodeC1).ShouldBe(DataPlaneAuthorityProjection.Empty);
+ DeploymentArtifact.ParseComposition(empty).EquipmentNodes.ShouldBeEmpty();
+ DeploymentArtifact.ParseComposition(empty, NodeC1).EquipmentNodes.ShouldBeEmpty();
+ }
+
+ private static void AssertSameComposition(AddressSpaceComposition actual, AddressSpaceComposition expected)
+ {
+ actual.UnsAreas.ShouldBe(expected.UnsAreas);
+ actual.UnsLines.ShouldBe(expected.UnsLines);
+ actual.EquipmentNodes.ShouldBe(expected.EquipmentNodes);
+ actual.DriverInstancePlans.ShouldBe(expected.DriverInstancePlans);
+ actual.ScriptedAlarmPlans.ShouldBe(expected.ScriptedAlarmPlans);
+ actual.EquipmentTags.ShouldBe(expected.EquipmentTags);
+ actual.EquipmentVirtualTags.ShouldBe(expected.EquipmentVirtualTags);
+ actual.EquipmentScriptedAlarms.ShouldBe(expected.EquipmentScriptedAlarms);
+ actual.RawContainers.ShouldBe(expected.RawContainers);
+ actual.RawTags.ShouldBe(expected.RawTags);
+ actual.UnsReferenceVariables.ShouldBe(expected.UnsReferenceVariables);
+ }
+
+ private static void AssertSameAuthority(DataPlaneAuthorityProjection actual, DataPlaneAuthorityProjection expected)
+ {
+ actual.ClusterId.ShouldBe(expected.ClusterId);
+ actual.Keys.Select(Describe).ShouldBe(expected.Keys.Select(Describe));
+ actual.Grants.Select(Describe).ShouldBe(expected.Grants.Select(Describe));
+
+ actual.ScopePaths.Keys.OrderBy(k => k.ToString()).ShouldBe(expected.ScopePaths.Keys.OrderBy(k => k.ToString()));
+ foreach (var (scope, paths) in expected.ScopePaths)
+ {
+ actual.ScopePaths.ShouldContainKey(scope);
+ actual.ScopePaths[scope].Select(p => string.Join('/', p.Segments))
+ .ShouldBe(paths.Select(p => string.Join('/', p.Segments)));
+ }
+ }
+
+ // ApiKey / NodeAcl are EF entities (reference equality), so compare their fields as a flat string.
+ private static string Describe(ZB.MOM.WW.OtOpcUa.Configuration.Entities.ApiKey k) =>
+ $"{k.ApiKeyId}|{k.Name}|{k.SecretHash}|{k.Principal}|{k.Enabled}|{k.CreatedUtc:O}|{k.ExpiresUtc:O}|{k.Notes}";
+
+ private static string Describe(ZB.MOM.WW.OtOpcUa.Configuration.Entities.NodeAcl a) =>
+ $"{a.NodeAclId}|{a.ClusterId}|{a.Principal}|{a.ScopeKind}|{a.ScopeId}|{a.PermissionFlags}|{a.Notes}";
+}