Auto: opcuaclient-7 — selective import + namespace remap

Adds OpcUaClientCurationOptions on OpcUaClientDriverOptions.Curation with:
- IncludePaths/ExcludePaths globs (* and ? only) matched against the
  slash-joined BrowsePath segments collected during BrowseRecursiveAsync.
  Empty Include = include all so existing deployments are unaffected;
  Exclude wins over Include. Pruned folders are skipped wholesale, so
  descendants don't reach the wire.
- NamespaceRemap (URI -> URI) applied to DriverAttributeInfo.FullName when
  registering variables. Index-0 / standard nodes round-trip unchanged;
  remapped nodes serialise via ExpandedNodeId so downstream clients see
  the local-side URI.
- RootAlias replaces the hard-coded "Remote" folder name at the top of
  the mirrored tree.

Closes #279
This commit is contained in:
Joseph Doherty
2026-04-25 20:20:47 -04:00
parent 1d3e9a3237
commit 02d1c85190
3 changed files with 318 additions and 7 deletions

View File

@@ -1,4 +1,5 @@
using System.Security.Cryptography.X509Certificates; using System.Security.Cryptography.X509Certificates;
using System.Text.RegularExpressions;
using Opc.Ua; using Opc.Ua;
using Opc.Ua.Client; using Opc.Ua.Client;
using Opc.Ua.Configuration; using Opc.Ua.Configuration;
@@ -1001,11 +1002,19 @@ public sealed class OpcUaClientDriver(OpcUaClientDriverOptions options, string d
? NodeId.Parse(session.MessageContext, _options.BrowseRoot) ? NodeId.Parse(session.MessageContext, _options.BrowseRoot)
: ObjectIds.ObjectsFolder; : ObjectIds.ObjectsFolder;
var rootFolder = builder.Folder("Remote", "Remote"); var rootName = string.IsNullOrWhiteSpace(_options.Curation.RootAlias)
? "Remote"
: _options.Curation.RootAlias!;
var rootFolder = builder.Folder(rootName, rootName);
var visited = new HashSet<NodeId>(); var visited = new HashSet<NodeId>();
var discovered = 0; var discovered = 0;
var pendingVariables = new List<PendingVariable>(); var pendingVariables = new List<PendingVariable>();
// Compile curation globs once per Discover so the recursion's hot path is a regex
// match rather than a per-segment string-walk. Empty include = include all.
var includeRegex = CompileGlobs(_options.Curation.IncludePaths);
var excludeRegex = CompileGlobs(_options.Curation.ExcludePaths);
await _gate.WaitAsync(cancellationToken).ConfigureAwait(false); await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
try try
{ {
@@ -1017,6 +1026,9 @@ public sealed class OpcUaClientDriver(OpcUaClientDriverOptions options, string d
// IAddressSpaceBuilder contract doesn't expose. // IAddressSpaceBuilder contract doesn't expose.
await BrowseRecursiveAsync(session, root, rootFolder, visited, await BrowseRecursiveAsync(session, root, rootFolder, visited,
depth: 0, depth: 0,
pathPrefix: string.Empty,
includeRegex: includeRegex,
excludeRegex: excludeRegex,
discovered: () => discovered, increment: () => discovered++, discovered: () => discovered, increment: () => discovered++,
pendingVariables: pendingVariables, pendingVariables: pendingVariables,
ct: cancellationToken).ConfigureAwait(false); ct: cancellationToken).ConfigureAwait(false);
@@ -1030,6 +1042,57 @@ public sealed class OpcUaClientDriver(OpcUaClientDriverOptions options, string d
finally { _gate.Release(); } finally { _gate.Release(); }
} }
/// <summary>
/// Translate the curation glob list into a single regex that matches if any pattern
/// matches. Returns null for null/empty input so the call site can short-circuit
/// without allocating.
/// </summary>
/// <remarks>
/// Glob semantics — see <see cref="OpcUaClientCurationOptions"/> remarks. Only
/// <c>*</c> (any sequence) and <c>?</c> (single char) are honoured; every other
/// character is regex-escaped.
/// </remarks>
internal static Regex? CompileGlobs(IReadOnlyList<string>? patterns)
{
if (patterns is null || patterns.Count == 0) return null;
var alternatives = new List<string>(patterns.Count);
foreach (var p in patterns)
{
if (string.IsNullOrEmpty(p)) continue;
alternatives.Add(GlobToRegex(p));
}
if (alternatives.Count == 0) return null;
var combined = "^(?:" + string.Join("|", alternatives) + ")$";
return new Regex(combined, RegexOptions.Compiled | RegexOptions.CultureInvariant);
}
private static string GlobToRegex(string glob)
{
var sb = new System.Text.StringBuilder(glob.Length * 2);
foreach (var ch in glob)
{
switch (ch)
{
case '*': sb.Append(".*"); break;
case '?': sb.Append('.'); break;
default: sb.Append(Regex.Escape(ch.ToString())); break;
}
}
return sb.ToString();
}
/// <summary>
/// Apply the configured curation rules to a candidate BrowsePath. Returns
/// <c>true</c> when the node should be included. Empty include = include all;
/// exclude wins over include.
/// </summary>
internal static bool ShouldInclude(string path, Regex? include, Regex? exclude)
{
if (exclude is not null && exclude.IsMatch(path)) return false;
if (include is null) return true;
return include.IsMatch(path);
}
/// <summary> /// <summary>
/// A variable collected during the browse pass, waiting for attribute enrichment /// A variable collected during the browse pass, waiting for attribute enrichment
/// before being registered on the address-space builder. /// before being registered on the address-space builder.
@@ -1038,11 +1101,13 @@ public sealed class OpcUaClientDriver(OpcUaClientDriverOptions options, string d
IAddressSpaceBuilder ParentFolder, IAddressSpaceBuilder ParentFolder,
string BrowseName, string BrowseName,
string DisplayName, string DisplayName,
NodeId NodeId); NodeId NodeId,
string FullName);
private async Task BrowseRecursiveAsync( private async Task BrowseRecursiveAsync(
ISession session, NodeId node, IAddressSpaceBuilder folder, HashSet<NodeId> visited, ISession session, NodeId node, IAddressSpaceBuilder folder, HashSet<NodeId> visited,
int depth, Func<int> discovered, Action increment, int depth, string pathPrefix, Regex? includeRegex, Regex? excludeRegex,
Func<int> discovered, Action increment,
List<PendingVariable> pendingVariables, CancellationToken ct) List<PendingVariable> pendingVariables, CancellationToken ct)
{ {
if (depth >= _options.MaxBrowseDepth) return; if (depth >= _options.MaxBrowseDepth) return;
@@ -1093,22 +1158,55 @@ public sealed class OpcUaClientDriver(OpcUaClientDriverOptions options, string d
var browseName = rf.BrowseName?.Name ?? childId.ToString(); var browseName = rf.BrowseName?.Name ?? childId.ToString();
var displayName = rf.DisplayName?.Text ?? browseName; var displayName = rf.DisplayName?.Text ?? browseName;
var childPath = string.IsNullOrEmpty(pathPrefix) ? browseName : pathPrefix + "/" + browseName;
// Apply curation: exclude wins over include; empty include = include all.
// Folders pruned here aren't browsed, so descendants don't reach the wire — keeps
// the cost down on large servers.
if (!ShouldInclude(childPath, includeRegex, excludeRegex))
continue;
if (rf.NodeClass == NodeClass.Object) if (rf.NodeClass == NodeClass.Object)
{ {
var subFolder = folder.Folder(browseName, displayName); var subFolder = folder.Folder(browseName, displayName);
increment(); increment();
await BrowseRecursiveAsync(session, childId, subFolder, visited, await BrowseRecursiveAsync(session, childId, subFolder, visited,
depth + 1, discovered, increment, pendingVariables, ct).ConfigureAwait(false); depth + 1, childPath, includeRegex, excludeRegex,
discovered, increment, pendingVariables, ct).ConfigureAwait(false);
} }
else if (rf.NodeClass == NodeClass.Variable) else if (rf.NodeClass == NodeClass.Variable)
{ {
pendingVariables.Add(new PendingVariable(folder, browseName, displayName, childId)); var fullName = BuildRemappedFullName(childId, session.NamespaceUris,
_options.Curation.NamespaceRemap);
pendingVariables.Add(new PendingVariable(folder, browseName, displayName, childId, fullName));
increment(); increment();
} }
} }
} }
/// <summary>
/// Render a NodeId as the canonical <c>nsu=&lt;uri&gt;;…</c> string, applying the
/// configured upstream→local namespace-URI remap. Index-namespace nodes (<c>ns=0</c>,
/// standard OPC UA nodes) bypass remap and use the legacy index-form so the
/// base-namespace round-trips unchanged. When remap is null/empty the result is the
/// SDK's default <c>NodeId.ToString()</c>.
/// </summary>
internal static string BuildRemappedFullName(NodeId nodeId, NamespaceTable? table,
IReadOnlyDictionary<string, string>? remap)
{
if (nodeId is null) return string.Empty;
var defaultName = nodeId.ToString() ?? string.Empty;
if (remap is null || remap.Count == 0) return defaultName;
if (nodeId.NamespaceIndex == 0 || table is null) return defaultName;
var upstreamUri = table.GetString(nodeId.NamespaceIndex);
if (string.IsNullOrEmpty(upstreamUri)) return defaultName;
if (!remap.TryGetValue(upstreamUri, out var localUri) || string.IsNullOrEmpty(localUri))
return defaultName;
// ExpandedNodeId.Format with an explicit URI emits "nsu=<uri>;<idType>=<value>" form.
var expanded = new ExpandedNodeId(nodeId.Identifier, 0, localUri, 0);
return expanded.ToString() ?? defaultName;
}
/// <summary> /// <summary>
/// Pass 2 of discovery: batch-read DataType + ValueRank + AccessLevel + Historizing /// Pass 2 of discovery: batch-read DataType + ValueRank + AccessLevel + Historizing
/// for every collected variable in one Session.ReadAsync (the SDK chunks internally /// for every collected variable in one Session.ReadAsync (the SDK chunks internally
@@ -1186,7 +1284,7 @@ public sealed class OpcUaClientDriver(OpcUaClientDriverOptions options, string d
var historizing = StatusCode.IsGood(histDv.StatusCode) && histDv.Value is bool b && b; var historizing = StatusCode.IsGood(histDv.StatusCode) && histDv.Value is bool b && b;
pv.ParentFolder.Variable(pv.BrowseName, pv.DisplayName, new DriverAttributeInfo( pv.ParentFolder.Variable(pv.BrowseName, pv.DisplayName, new DriverAttributeInfo(
FullName: pv.NodeId.ToString() ?? string.Empty, FullName: pv.FullName,
DriverDataType: dataType, DriverDataType: dataType,
IsArray: isArray, IsArray: isArray,
ArrayDim: null, ArrayDim: null,
@@ -1198,7 +1296,7 @@ public sealed class OpcUaClientDriver(OpcUaClientDriverOptions options, string d
void RegisterFallback(PendingVariable pv) void RegisterFallback(PendingVariable pv)
{ {
pv.ParentFolder.Variable(pv.BrowseName, pv.DisplayName, new DriverAttributeInfo( pv.ParentFolder.Variable(pv.BrowseName, pv.DisplayName, new DriverAttributeInfo(
FullName: pv.NodeId.ToString() ?? string.Empty, FullName: pv.FullName,
DriverDataType: DriverDataType.Int32, DriverDataType: DriverDataType.Int32,
IsArray: false, IsArray: false,
ArrayDim: null, ArrayDim: null,

View File

@@ -181,8 +181,66 @@ public sealed class OpcUaClientDriverOptions
/// since SHA-1 is spec-deprecated for OPC UA. /// since SHA-1 is spec-deprecated for OPC UA.
/// </summary> /// </summary>
public OpcUaCertificateValidationOptions CertificateValidation { get; init; } = new(); public OpcUaCertificateValidationOptions CertificateValidation { get; init; } = new();
/// <summary>
/// Curation rules applied to the upstream address space during
/// <c>DiscoverAsync</c>. Lets operators trim the mirrored tree to the subset their
/// downstream clients actually need, rename namespace URIs so the local-side metadata
/// stays consistent across upstream-server swaps, and override the default
/// <c>"Remote"</c> root folder name. Defaults are empty / null which preserves the
/// pre-curation behaviour exactly — empty include = include all.
/// </summary>
public OpcUaClientCurationOptions Curation { get; init; } = new();
} }
/// <summary>
/// Selective import + namespace remap rules for the OPC UA Client driver. Pure local
/// filtering inside <c>BrowseRecursiveAsync</c> + <c>EnrichAndRegisterVariablesAsync</c>;
/// no new SDK calls.
/// </summary>
/// <remarks>
/// <para>
/// <b>Glob semantics</b>: patterns are matched against the slash-joined BrowseName
/// segments accumulated during the browse pass (e.g. <c>"Server/Diagnostics/SessionsDiagnosticsArray"</c>).
/// Two wildcards are supported — <c>*</c> matches any sequence of characters
/// (including empty / slashes) and <c>?</c> matches exactly one character. No
/// character classes, no <c>**</c>, no escapes — keep the surface tight so the doc
/// + behaviour stay simple.
/// </para>
/// <para>
/// Empty <see cref="IncludePaths"/> = include all (existing behaviour).
/// <see cref="ExcludePaths"/> wins over <see cref="IncludePaths"/> when both match.
/// Folders pruned by the rules are skipped wholesale — their descendants don't get
/// browsed, which keeps the wire cost down on large servers.
/// </para>
/// </remarks>
/// <param name="IncludePaths">
/// Glob patterns matched against the BrowsePath segment list. Empty = include all
/// (default — preserves pre-curation behaviour).
/// </param>
/// <param name="ExcludePaths">
/// Glob patterns matched against the BrowsePath segment list. Wins over
/// <see cref="IncludePaths"/> — useful for "include everything under <c>Plant/*</c>
/// except <c>Plant/Diagnostics</c>" rules.
/// </param>
/// <param name="NamespaceRemap">
/// Upstream-namespace-URI → local-namespace-URI translation table applied to the
/// <c>FullName</c> field of <c>DriverAttributeInfo</c> when registering variables.
/// The driver's stored <c>FullName</c> swaps the prefix before persisting so downstream
/// clients see the remapped URI. Lookup is case-sensitive — match the upstream URI
/// exactly. Defaults to empty (no remap).
/// </param>
/// <param name="RootAlias">
/// Replaces the default <c>"Remote"</c> folder name at the top of the mirrored tree.
/// Useful when multiple OPC UA Client drivers are aggregated and operators need to
/// distinguish them in the local browse tree. Default <c>null</c> = use <c>"Remote"</c>.
/// </param>
public sealed record OpcUaClientCurationOptions(
IReadOnlyList<string>? IncludePaths = null,
IReadOnlyList<string>? ExcludePaths = null,
IReadOnlyDictionary<string, string>? NamespaceRemap = null,
string? RootAlias = null);
/// <summary> /// <summary>
/// Knobs governing the server-certificate validation callback. Plumbed onto /// Knobs governing the server-certificate validation callback. Plumbed onto
/// <see cref="OpcUaClientDriverOptions.CertificateValidation"/> rather than the top-level /// <see cref="OpcUaClientDriverOptions.CertificateValidation"/> rather than the top-level

View File

@@ -0,0 +1,155 @@
using Opc.Ua;
using Shouldly;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests;
/// <summary>
/// Unit tests for the curation surface added in PR-7 (selective import + namespace
/// remap + root-alias). Focused on the pure-function helpers
/// (<see cref="OpcUaClientDriver.CompileGlobs"/>,
/// <see cref="OpcUaClientDriver.ShouldInclude"/>,
/// <see cref="OpcUaClientDriver.BuildRemappedFullName"/>) so the assertions don't need a
/// live upstream server. End-to-end browse + filter coverage lands in the integration
/// tests against opc-plc.
/// </summary>
[Trait("Category", "Unit")]
public sealed class OpcUaClientCurationTests
{
[Fact]
public void CurationOptions_default_is_empty_so_existing_behaviour_is_preserved()
{
var opts = new OpcUaClientDriverOptions();
opts.Curation.IncludePaths.ShouldBeNull();
opts.Curation.ExcludePaths.ShouldBeNull();
opts.Curation.NamespaceRemap.ShouldBeNull();
opts.Curation.RootAlias.ShouldBeNull();
}
[Fact]
public void ShouldInclude_empty_include_means_include_all()
{
OpcUaClientDriver.ShouldInclude("Server/Status", include: null, exclude: null).ShouldBeTrue();
OpcUaClientDriver.ShouldInclude("anything", include: null, exclude: null).ShouldBeTrue();
}
[Fact]
public void ShouldInclude_star_glob_matches_any_segment_chars()
{
var include = OpcUaClientDriver.CompileGlobs(new[] { "Plant/*" });
OpcUaClientDriver.ShouldInclude("Plant/Tank1", include, null).ShouldBeTrue();
OpcUaClientDriver.ShouldInclude("Plant/Tank1/Level", include, null).ShouldBeTrue("'*' matches across slashes — single-glob simple semantics");
OpcUaClientDriver.ShouldInclude("Other/Tank1", include, null).ShouldBeFalse();
}
[Fact]
public void ShouldInclude_question_mark_matches_exactly_one_char()
{
var include = OpcUaClientDriver.CompileGlobs(new[] { "Tank?" });
OpcUaClientDriver.ShouldInclude("Tank1", include, null).ShouldBeTrue();
OpcUaClientDriver.ShouldInclude("Tank12", include, null).ShouldBeFalse();
OpcUaClientDriver.ShouldInclude("Tank", include, null).ShouldBeFalse();
}
[Fact]
public void ShouldInclude_exclude_wins_over_include()
{
var include = OpcUaClientDriver.CompileGlobs(new[] { "Plant/*" });
var exclude = OpcUaClientDriver.CompileGlobs(new[] { "Plant/Diagnostics*" });
OpcUaClientDriver.ShouldInclude("Plant/Tank1", include, exclude).ShouldBeTrue();
OpcUaClientDriver.ShouldInclude("Plant/Diagnostics", include, exclude).ShouldBeFalse();
OpcUaClientDriver.ShouldInclude("Plant/DiagnosticsTree/Sub", include, exclude).ShouldBeFalse();
}
[Fact]
public void ShouldInclude_multiple_includes_are_OR()
{
var include = OpcUaClientDriver.CompileGlobs(new[] { "PlantA/*", "PlantB/*" });
OpcUaClientDriver.ShouldInclude("PlantA/Tank", include, null).ShouldBeTrue();
OpcUaClientDriver.ShouldInclude("PlantB/Tank", include, null).ShouldBeTrue();
OpcUaClientDriver.ShouldInclude("PlantC/Tank", include, null).ShouldBeFalse();
}
[Fact]
public void CompileGlobs_returns_null_for_empty_or_null_input()
{
OpcUaClientDriver.CompileGlobs(null).ShouldBeNull();
OpcUaClientDriver.CompileGlobs(System.Array.Empty<string>()).ShouldBeNull();
OpcUaClientDriver.CompileGlobs(new[] { string.Empty }).ShouldBeNull();
}
[Fact]
public void CompileGlobs_escapes_regex_metacharacters_in_literal_segments()
{
// A bare "." in the glob must NOT match arbitrary chars — it's escaped to a literal dot.
var rx = OpcUaClientDriver.CompileGlobs(new[] { "App.exe" });
rx.ShouldNotBeNull();
rx!.IsMatch("App.exe").ShouldBeTrue();
rx.IsMatch("AppXexe").ShouldBeFalse();
}
[Fact]
public void BuildRemappedFullName_returns_default_string_when_remap_is_empty()
{
var node = new NodeId("Tank/Level", 2);
OpcUaClientDriver.BuildRemappedFullName(node, table: null, remap: null)
.ShouldBe(node.ToString());
}
[Fact]
public void BuildRemappedFullName_passes_through_namespace_zero_unchanged()
{
// Standard OPC UA nodes (ns=0) bypass remap so the base namespace round-trips.
var node = new NodeId(2253u); // Server
var remap = new Dictionary<string, string> { ["http://opcfoundation.org/UA/"] = "urn:local:base" };
OpcUaClientDriver.BuildRemappedFullName(node, table: null, remap: remap)
.ShouldBe(node.ToString());
}
[Fact]
public void BuildRemappedFullName_translates_upstream_uri_when_remap_hits()
{
var table = new NamespaceTable();
table.Append("urn:upstream:plc-vendor"); // index 1
var node = new NodeId("Tank/Level", 1);
var remap = new Dictionary<string, string>
{
["urn:upstream:plc-vendor"] = "urn:local:plant",
};
var result = OpcUaClientDriver.BuildRemappedFullName(node, table, remap);
result.ShouldContain("urn:local:plant", Case.Sensitive);
result.ShouldNotContain("urn:upstream:plc-vendor");
}
[Fact]
public void BuildRemappedFullName_no_op_when_upstream_uri_not_in_remap_table()
{
var table = new NamespaceTable();
table.Append("urn:upstream:other"); // index 1
var node = new NodeId("Some/Item", 1);
var remap = new Dictionary<string, string>
{
["urn:not-matching"] = "urn:local:something",
};
OpcUaClientDriver.BuildRemappedFullName(node, table, remap).ShouldBe(node.ToString());
}
[Fact]
public void RootAlias_default_null_means_Remote()
{
var opts = new OpcUaClientDriverOptions();
opts.Curation.RootAlias.ShouldBeNull();
}
[Fact]
public void RootAlias_can_be_set_via_Curation_record()
{
var opts = new OpcUaClientDriverOptions
{
Curation = new OpcUaClientCurationOptions(RootAlias: "Plant"),
};
opts.Curation.RootAlias.ShouldBe("Plant");
}
}