Merge pull request '[opcuaclient] OpcUaClient — Discovery URL FindServers' (#358) from auto/opcuaclient/6 into auto/driver-gaps
This commit was merged in pull request #358.
This commit is contained in:
@@ -127,7 +127,29 @@ public sealed class OpcUaClientDriver(OpcUaClientDriverOptions options, string d
|
||||
try
|
||||
{
|
||||
var appConfig = await BuildApplicationConfigurationAsync(cancellationToken).ConfigureAwait(false);
|
||||
var candidates = ResolveEndpointCandidates(_options);
|
||||
|
||||
// When DiscoveryUrl is set, run FindServers + GetEndpoints first and merge the
|
||||
// discovered URLs into the candidate list before the failover sweep. Discovery
|
||||
// failures are non-fatal: log + fall through to the statically configured
|
||||
// candidates so a transient LDS outage doesn't block init.
|
||||
IReadOnlyList<string> discovered = [];
|
||||
if (!string.IsNullOrWhiteSpace(_options.DiscoveryUrl))
|
||||
{
|
||||
try
|
||||
{
|
||||
discovered = await DiscoverEndpointsAsync(
|
||||
appConfig, _options.DiscoveryUrl!, _options.SecurityPolicy, _options.SecurityMode,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// Swallow + continue with static candidates; the failover sweep error
|
||||
// (if all static candidates also fail) will surface the situation.
|
||||
discovered = [];
|
||||
}
|
||||
}
|
||||
|
||||
var candidates = ResolveEndpointCandidates(_options, discovered);
|
||||
|
||||
var identity = BuildUserIdentity(_options);
|
||||
|
||||
@@ -390,10 +412,116 @@ public sealed class OpcUaClientDriver(OpcUaClientDriverOptions options, string d
|
||||
/// non-empty; otherwise fall back to <c>EndpointUrl</c> as a single-URL shortcut so
|
||||
/// existing single-endpoint configs keep working without migration.
|
||||
/// </summary>
|
||||
internal static IReadOnlyList<string> ResolveEndpointCandidates(OpcUaClientDriverOptions opts)
|
||||
internal static IReadOnlyList<string> ResolveEndpointCandidates(OpcUaClientDriverOptions opts) =>
|
||||
ResolveEndpointCandidates(opts, []);
|
||||
|
||||
/// <summary>
|
||||
/// Resolve the ordered failover candidate list with optional discovery results.
|
||||
/// Discovered URLs are <b>prepended</b> to the static candidate list so a discovery
|
||||
/// sweep gets first-attempt priority over hand-rolled fallbacks. When the static
|
||||
/// list is empty (no <see cref="OpcUaClientDriverOptions.EndpointUrls"/> AND only
|
||||
/// the default <see cref="OpcUaClientDriverOptions.EndpointUrl"/>), the discovered
|
||||
/// URLs replace the static candidate entirely so a pure-discovery deployment doesn't
|
||||
/// need a hard-coded fallback URL. Duplicates are removed (case-insensitive on the
|
||||
/// URL string) so a discovered URL that also appears in <c>EndpointUrls</c> isn't
|
||||
/// attempted twice in a row.
|
||||
/// </summary>
|
||||
internal static IReadOnlyList<string> ResolveEndpointCandidates(
|
||||
OpcUaClientDriverOptions opts,
|
||||
IReadOnlyList<string> discovered)
|
||||
{
|
||||
if (opts.EndpointUrls is { Count: > 0 }) return opts.EndpointUrls;
|
||||
return [opts.EndpointUrl];
|
||||
var staticList = opts.EndpointUrls is { Count: > 0 }
|
||||
? (IReadOnlyList<string>)opts.EndpointUrls
|
||||
: [opts.EndpointUrl];
|
||||
|
||||
if (discovered.Count == 0) return staticList;
|
||||
|
||||
// Discovered first; merge static after with case-insensitive de-dup so a single
|
||||
// server that appears in both lists doesn't cause two consecutive identical attempts.
|
||||
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
var merged = new List<string>(discovered.Count + staticList.Count);
|
||||
foreach (var u in discovered)
|
||||
if (!string.IsNullOrWhiteSpace(u) && seen.Add(u))
|
||||
merged.Add(u);
|
||||
foreach (var u in staticList)
|
||||
if (!string.IsNullOrWhiteSpace(u) && seen.Add(u))
|
||||
merged.Add(u);
|
||||
return merged;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Run OPC UA discovery against <paramref name="discoveryUrl"/>: <c>FindServers</c>
|
||||
/// enumerates every server registered with the LDS (or just the one server when
|
||||
/// <paramref name="discoveryUrl"/> points at a server directly), then
|
||||
/// <c>GetEndpoints</c> on each server's discovery URL pulls its full endpoint list.
|
||||
/// Endpoints are filtered to those matching the requested policy + mode before being
|
||||
/// returned.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <b>SecurityMode=None on the discovery channel</b> is mandated by the OPC UA spec —
|
||||
/// discovery is unauthenticated even when the steady-state session uses Sign or
|
||||
/// SignAndEncrypt. <c>DiscoveryClient.CreateAsync</c> opens an unsecured channel by
|
||||
/// default; we don't override that here.
|
||||
/// </remarks>
|
||||
internal static async Task<IReadOnlyList<string>> DiscoverEndpointsAsync(
|
||||
ApplicationConfiguration appConfig,
|
||||
string discoveryUrl,
|
||||
OpcUaSecurityPolicy policy,
|
||||
OpcUaSecurityMode mode,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var wantedPolicyUri = MapSecurityPolicy(policy);
|
||||
var wantedMode = mode switch
|
||||
{
|
||||
OpcUaSecurityMode.None => MessageSecurityMode.None,
|
||||
OpcUaSecurityMode.Sign => MessageSecurityMode.Sign,
|
||||
OpcUaSecurityMode.SignAndEncrypt => MessageSecurityMode.SignAndEncrypt,
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(mode)),
|
||||
};
|
||||
|
||||
var results = new List<string>();
|
||||
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
// FindServers against the LDS / server discovery endpoint. Returned ApplicationDescriptions
|
||||
// each carry one or more DiscoveryUrls (typically one per network interface).
|
||||
ApplicationDescriptionCollection servers;
|
||||
using (var lds = await DiscoveryClient.CreateAsync(
|
||||
appConfig, new Uri(discoveryUrl), DiagnosticsMasks.None, ct).ConfigureAwait(false))
|
||||
{
|
||||
servers = await lds.FindServersAsync(null, ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
foreach (var server in servers)
|
||||
{
|
||||
if (server.DiscoveryUrls is null) continue;
|
||||
foreach (var serverDiscoveryUrl in server.DiscoveryUrls)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(serverDiscoveryUrl)) continue;
|
||||
EndpointDescriptionCollection endpoints;
|
||||
try
|
||||
{
|
||||
using var ep = await DiscoveryClient.CreateAsync(
|
||||
appConfig, new Uri(serverDiscoveryUrl), DiagnosticsMasks.None, ct).ConfigureAwait(false);
|
||||
endpoints = await ep.GetEndpointsAsync(null, ct).ConfigureAwait(false);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// One unreachable server in the LDS list shouldn't blow up the whole
|
||||
// sweep — skip it and keep going.
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var e in endpoints)
|
||||
{
|
||||
if (e.SecurityPolicyUri != wantedPolicyUri) continue;
|
||||
if (e.SecurityMode != wantedMode) continue;
|
||||
if (string.IsNullOrWhiteSpace(e.EndpointUrl)) continue;
|
||||
if (seen.Add(e.EndpointUrl)) results.Add(e.EndpointUrl);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -39,6 +39,34 @@ public sealed class OpcUaClientDriverOptions
|
||||
/// </summary>
|
||||
public TimeSpan PerEndpointConnectTimeout { get; init; } = TimeSpan.FromSeconds(3);
|
||||
|
||||
/// <summary>
|
||||
/// Optional discovery URL pointing at a Local Discovery Server (LDS) or a server's
|
||||
/// own discovery endpoint. When set, the driver runs <c>FindServers</c> +
|
||||
/// <c>GetEndpoints</c> against this URL during <see cref="OpcUaClientDriver.InitializeAsync"/>
|
||||
/// and prepends the discovered endpoint URLs to the failover candidate list. When
|
||||
/// <see cref="EndpointUrls"/> is empty (and only <see cref="EndpointUrl"/> is set as
|
||||
/// a fallback), the discovered URLs replace the candidate list entirely so a
|
||||
/// discovery-driven deployment can be configured without specifying any endpoints
|
||||
/// up front. Discovery failures are non-fatal — the driver logs and falls back to the
|
||||
/// statically configured candidates.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>FindServers requires SecurityMode=None on the discovery channel</b> per the
|
||||
/// OPC UA spec — discovery is unauthenticated even when the data channel uses
|
||||
/// <c>Sign</c> or <c>SignAndEncrypt</c>. The driver opens the discovery channel
|
||||
/// unsecured regardless of <see cref="SecurityMode"/>; only the resulting data
|
||||
/// session is bound to the configured policy.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Endpoints returned by discovery are filtered to those matching
|
||||
/// <see cref="SecurityPolicy"/> + <see cref="SecurityMode"/> before being added to
|
||||
/// the candidate list, so a discovery sweep against a multi-policy server only
|
||||
/// surfaces endpoints the driver could actually connect to.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public string? DiscoveryUrl { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Security policy to require when selecting an endpoint. Either a
|
||||
/// <see cref="OpcUaSecurityPolicy"/> enum constant or a free-form string (for
|
||||
|
||||
@@ -55,6 +55,85 @@ public sealed class OpcUaClientFailoverTests
|
||||
"pre-connect the dashboard should show the first candidate URL so operators can link back");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DiscoveryUrl_defaults_null_so_existing_configs_are_unaffected()
|
||||
{
|
||||
var opts = new OpcUaClientDriverOptions();
|
||||
opts.DiscoveryUrl.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveEndpointCandidates_prepends_discovered_urls_before_static_candidates()
|
||||
{
|
||||
var opts = new OpcUaClientDriverOptions
|
||||
{
|
||||
EndpointUrls = ["opc.tcp://static1:4840", "opc.tcp://static2:4841"],
|
||||
};
|
||||
var discovered = new[] { "opc.tcp://discovered1:4840", "opc.tcp://discovered2:4841" };
|
||||
var list = OpcUaClientDriver.ResolveEndpointCandidates(opts, discovered);
|
||||
list.Count.ShouldBe(4);
|
||||
list[0].ShouldBe("opc.tcp://discovered1:4840");
|
||||
list[1].ShouldBe("opc.tcp://discovered2:4841");
|
||||
list[2].ShouldBe("opc.tcp://static1:4840");
|
||||
list[3].ShouldBe("opc.tcp://static2:4841");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveEndpointCandidates_dedupes_url_appearing_in_both_discovered_and_static()
|
||||
{
|
||||
var opts = new OpcUaClientDriverOptions
|
||||
{
|
||||
EndpointUrls = ["opc.tcp://shared:4840", "opc.tcp://static:4841"],
|
||||
};
|
||||
var discovered = new[] { "opc.tcp://shared:4840", "opc.tcp://only-discovered:4842" };
|
||||
var list = OpcUaClientDriver.ResolveEndpointCandidates(opts, discovered);
|
||||
list.Count.ShouldBe(3);
|
||||
list[0].ShouldBe("opc.tcp://shared:4840");
|
||||
list[1].ShouldBe("opc.tcp://only-discovered:4842");
|
||||
list[2].ShouldBe("opc.tcp://static:4841");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveEndpointCandidates_dedup_is_case_insensitive()
|
||||
{
|
||||
// Discovery URLs sometimes return uppercase hostnames; static config typically has
|
||||
// lowercase. The de-dup should treat them as the same URL so the failover sweep
|
||||
// doesn't attempt the same host twice in a row.
|
||||
var opts = new OpcUaClientDriverOptions
|
||||
{
|
||||
EndpointUrls = ["opc.tcp://host:4840"],
|
||||
};
|
||||
var discovered = new[] { "OPC.TCP://HOST:4840" };
|
||||
var list = OpcUaClientDriver.ResolveEndpointCandidates(opts, discovered);
|
||||
list.Count.ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveEndpointCandidates_with_only_default_endpoint_is_replaced_by_discovery()
|
||||
{
|
||||
// No EndpointUrls list, default EndpointUrl — the static "candidate" is the default
|
||||
// localhost shortcut. When discovery returns URLs they should still be prepended
|
||||
// (the localhost default isn't worth filtering out specially since it's harmless to
|
||||
// try last and it's still a valid configured fallback).
|
||||
var opts = new OpcUaClientDriverOptions(); // EndpointUrl=opc.tcp://localhost:4840 default
|
||||
var discovered = new[] { "opc.tcp://discovered:4840" };
|
||||
var list = OpcUaClientDriver.ResolveEndpointCandidates(opts, discovered);
|
||||
list[0].ShouldBe("opc.tcp://discovered:4840");
|
||||
list.ShouldContain("opc.tcp://localhost:4840");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveEndpointCandidates_no_discovered_falls_back_to_static_behaviour()
|
||||
{
|
||||
var opts = new OpcUaClientDriverOptions
|
||||
{
|
||||
EndpointUrls = ["opc.tcp://only:4840"],
|
||||
};
|
||||
var list = OpcUaClientDriver.ResolveEndpointCandidates(opts, []);
|
||||
list.Count.ShouldBe(1);
|
||||
list[0].ShouldBe("opc.tcp://only:4840");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Initialize_against_all_unreachable_endpoints_throws_AggregateException_listing_each()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user