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}"; +}