feat(galaxy.browser): add transient gateway-connection factory
GalaxyDriverBrowser opens an ad-hoc GalaxyRepositoryClient from the AdminUI's persisted Galaxy options and hands it to a GalaxyBrowseSession for the address picker. Mirrors GalaxyDriver.BuildClientOptions field- for-field so the gateway sees an identical option shape, with API-key resolution inlined (env:/file:/dev: prefixes) so the Browser project needn't take a hard reference on Driver.Galaxy. Connect phase runs under a 30s budget linked to the caller's CT and includes a TestConnectionAsync call so auth/TLS/DNS failures surface inside the budget instead of waiting for the first DiscoverHierarchy round-trip. On any post-Create exception the client is disposed before the throw propagates. Refactored GalaxyBrowseSession to take only GalaxyRepositoryClient — browse never needs MxGatewaySession (that's only for live subscribe/ write paths), and constructing one outside the runtime driver isn't straightforward. The session now disposes _client in DisposeAsync; the _session field/parameter is gone.
This commit is contained in:
@@ -12,11 +12,12 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser;
|
|||||||
/// caches it by <c>TagName</c> (and <c>GobjectId</c> for parent lookup), and serves
|
/// caches it by <c>TagName</c> (and <c>GobjectId</c> for parent lookup), and serves
|
||||||
/// subsequent <see cref="ExpandAsync"/> calls in-memory. Attribute fetches are
|
/// subsequent <see cref="ExpandAsync"/> calls in-memory. Attribute fetches are
|
||||||
/// per-object via <c>DiscoverHierarchyAsync(MaxDepth=0, IncludeAttributes=true)</c>.
|
/// per-object via <c>DiscoverHierarchyAsync(MaxDepth=0, IncludeAttributes=true)</c>.
|
||||||
/// Owns the supplied <see cref="MxGatewaySession"/> and disposes it best-effort.
|
/// Owns the supplied <see cref="GalaxyRepositoryClient"/> and disposes it
|
||||||
|
/// best-effort. (Browse does not need an <c>MxGatewaySession</c> — that's only
|
||||||
|
/// required for live subscribe/write paths handled by the runtime driver.)
|
||||||
/// </summary>
|
/// </summary>
|
||||||
internal sealed class GalaxyBrowseSession : IBrowseSession
|
internal sealed class GalaxyBrowseSession : IBrowseSession
|
||||||
{
|
{
|
||||||
private readonly MxGatewaySession _session;
|
|
||||||
private readonly GalaxyRepositoryClient _client;
|
private readonly GalaxyRepositoryClient _client;
|
||||||
private readonly ConcurrentDictionary<string, GalaxyObject> _byTagName = new(StringComparer.Ordinal);
|
private readonly ConcurrentDictionary<string, GalaxyObject> _byTagName = new(StringComparer.Ordinal);
|
||||||
private readonly ConcurrentDictionary<int, GalaxyObject> _byGobjectId = new();
|
private readonly ConcurrentDictionary<int, GalaxyObject> _byGobjectId = new();
|
||||||
@@ -31,15 +32,14 @@ internal sealed class GalaxyBrowseSession : IBrowseSession
|
|||||||
public DateTime LastUsedUtc { get; private set; } = DateTime.UtcNow;
|
public DateTime LastUsedUtc { get; private set; } = DateTime.UtcNow;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Initializes a new session wrapping a connected gateway client. The factory
|
/// Initializes a new session wrapping a connected repository client. The factory
|
||||||
/// in <c>GalaxyDriverBrowser</c> (Task 9) constructs both the session and the
|
/// in <c>GalaxyDriverBrowser</c> (Task 9) constructs the client via
|
||||||
/// repository client and hands them off here for the session's lifetime.
|
/// <see cref="GalaxyRepositoryClient.Create"/> and hands it off here for the
|
||||||
|
/// session's lifetime.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="session">Gateway session to dispose when the browse closes.</param>
|
|
||||||
/// <param name="client">Galaxy repository client to query for hierarchy and attributes.</param>
|
/// <param name="client">Galaxy repository client to query for hierarchy and attributes.</param>
|
||||||
internal GalaxyBrowseSession(MxGatewaySession session, GalaxyRepositoryClient client)
|
internal GalaxyBrowseSession(GalaxyRepositoryClient client)
|
||||||
{
|
{
|
||||||
_session = session ?? throw new ArgumentNullException(nameof(session));
|
|
||||||
_client = client ?? throw new ArgumentNullException(nameof(client));
|
_client = client ?? throw new ArgumentNullException(nameof(client));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -186,7 +186,7 @@ internal sealed class GalaxyBrowseSession : IBrowseSession
|
|||||||
};
|
};
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Idempotently tears down the underlying gateway session. Swallows exceptions
|
/// Idempotently tears down the underlying repository client. Swallows exceptions
|
||||||
/// on shutdown — the registry's reaper may be racing a client-initiated close.
|
/// on shutdown — the registry's reaper may be racing a client-initiated close.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public async ValueTask DisposeAsync()
|
public async ValueTask DisposeAsync()
|
||||||
@@ -196,7 +196,7 @@ internal sealed class GalaxyBrowseSession : IBrowseSession
|
|||||||
_rootGate.Dispose();
|
_rootGate.Dispose();
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await _session.DisposeAsync().ConfigureAwait(false);
|
await _client.DisposeAsync().ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,192 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
using MxGateway.Client;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Commons.Browsing;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Config;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Opens transient gateway connections for the AdminUI address picker. Mirrors the
|
||||||
|
/// runtime <c>GalaxyDriver.BuildClientOptions</c> pattern so the gateway sees the
|
||||||
|
/// same option shape, but tweaks <see cref="MxGatewayClientOptions.ApiKey"/>
|
||||||
|
/// resolution + the gateway-side client identity so browse sessions are
|
||||||
|
/// distinguishable from the runtime driver's live MX session.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class GalaxyDriverBrowser : IDriverBrowser
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Identifier used in gateway-side logs / metrics for AdminUI browse sessions.
|
||||||
|
/// Distinct from any runtime driver's <c>MxAccess.ClientName</c> so an operator
|
||||||
|
/// can tell the two apart when triaging.
|
||||||
|
/// </summary>
|
||||||
|
internal const string BrowseClientIdentity = "OtOpcUa-AdminUI-Browse";
|
||||||
|
|
||||||
|
/// <summary>Hard cap on the time we'll wait for the initial gateway handshake.</summary>
|
||||||
|
private static readonly TimeSpan ConnectBudget = TimeSpan.FromSeconds(30);
|
||||||
|
|
||||||
|
private static readonly JsonSerializerOptions JsonOpts = new()
|
||||||
|
{
|
||||||
|
UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip,
|
||||||
|
PropertyNameCaseInsensitive = true,
|
||||||
|
};
|
||||||
|
|
||||||
|
private readonly ILogger<GalaxyDriverBrowser> _logger;
|
||||||
|
|
||||||
|
/// <summary>Creates a new browser. Logger defaults to <see cref="NullLogger{T}"/>.</summary>
|
||||||
|
/// <param name="logger">Optional logger; null is allowed for unit-test construction.</param>
|
||||||
|
public GalaxyDriverBrowser(ILogger<GalaxyDriverBrowser>? logger = null)
|
||||||
|
{
|
||||||
|
_logger = logger ?? NullLogger<GalaxyDriverBrowser>.Instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Driver type key — matches the AdminUI's persisted "Galaxy" value.</summary>
|
||||||
|
public string DriverType => "Galaxy";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Deserializes a <see cref="GalaxyDriverOptions"/> blob, opens a transient
|
||||||
|
/// <see cref="GalaxyRepositoryClient"/> against the configured gateway endpoint,
|
||||||
|
/// and returns a browse session over it. The session owns the client and disposes
|
||||||
|
/// it on <see cref="IBrowseSession.DisposeAsync"/>.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="configJson">Driver options serialized as JSON; same shape the runtime
|
||||||
|
/// driver would consume.</param>
|
||||||
|
/// <param name="cancellationToken">Cancellation for the connect phase only.</param>
|
||||||
|
/// <exception cref="InvalidOperationException">
|
||||||
|
/// Thrown when the JSON deserialises to null, when <c>Gateway.Endpoint</c> is empty,
|
||||||
|
/// or when <c>MxAccess.ClientName</c> is empty.
|
||||||
|
/// </exception>
|
||||||
|
public async Task<IBrowseSession> OpenAsync(string configJson, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var opts = JsonSerializer.Deserialize<GalaxyDriverOptions>(configJson, JsonOpts)
|
||||||
|
?? throw new InvalidOperationException("Galaxy options deserialized to null.");
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(opts.Gateway.Endpoint))
|
||||||
|
throw new InvalidOperationException("Galaxy browser requires Gateway.Endpoint.");
|
||||||
|
|
||||||
|
// The form persists MXAccess identity as ClientName (there is no separate
|
||||||
|
// "galaxy name" knob on the driver — the gateway picks the galaxy via its
|
||||||
|
// own GalaxyRepository config). Refuse a blank ClientName so the gateway side
|
||||||
|
// doesn't see anonymous browse sessions during triage.
|
||||||
|
if (string.IsNullOrWhiteSpace(opts.MxAccess.ClientName))
|
||||||
|
throw new InvalidOperationException("Galaxy browser requires MxAccess.ClientName.");
|
||||||
|
|
||||||
|
var clientOpts = BuildClientOptions(opts.Gateway);
|
||||||
|
|
||||||
|
// 30s wall-clock budget for the connect phase, linked to the caller's token so
|
||||||
|
// an AdminUI cancel still wins early.
|
||||||
|
using var connectCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||||
|
connectCts.CancelAfter(ConnectBudget);
|
||||||
|
|
||||||
|
GalaxyRepositoryClient? client = null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
client = GalaxyRepositoryClient.Create(clientOpts);
|
||||||
|
|
||||||
|
// TestConnectionAsync gives the gateway a chance to surface auth / TLS / DNS
|
||||||
|
// failures synchronously inside the connect budget rather than waiting for
|
||||||
|
// the first DiscoverHierarchyAsync call to fail. The client's own
|
||||||
|
// ConnectTimeout already bounds the underlying gRPC handshake; the linked
|
||||||
|
// CTS layered on top guarantees the AdminUI never blocks past 30s.
|
||||||
|
await client.TestConnectionAsync(connectCts.Token).ConfigureAwait(false);
|
||||||
|
|
||||||
|
_logger.LogInformation(
|
||||||
|
"AdminUI Galaxy browse session opened against {Endpoint} (admin-client={Identity}, runtime-client={RuntimeClient})",
|
||||||
|
opts.Gateway.Endpoint, BrowseClientIdentity, opts.MxAccess.ClientName);
|
||||||
|
|
||||||
|
var session = new GalaxyBrowseSession(client);
|
||||||
|
client = null; // Ownership transferred — keep finally from disposing.
|
||||||
|
return session;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
if (client is not null)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await client.DisposeAsync().ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Best-effort cleanup; the original exception is more useful.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Build the gateway client options from the form's Gateway section. Mirrors the
|
||||||
|
/// runtime driver's <c>GalaxyDriver.BuildClientOptions</c> field-for-field so the
|
||||||
|
/// gateway sees an identical option shape. The API-key reference is resolved
|
||||||
|
/// inline (a slim version of <c>GalaxyDriver.ResolveApiKey</c>) because the
|
||||||
|
/// Browser project doesn't reference Driver.Galaxy.
|
||||||
|
/// </summary>
|
||||||
|
private MxGatewayClientOptions BuildClientOptions(GalaxyGatewayOptions gw) => new()
|
||||||
|
{
|
||||||
|
Endpoint = new Uri(gw.Endpoint, UriKind.Absolute),
|
||||||
|
ApiKey = ResolveApiKey(gw.ApiKeySecretRef),
|
||||||
|
UseTls = gw.UseTls,
|
||||||
|
CaCertificatePath = gw.CaCertificatePath,
|
||||||
|
ConnectTimeout = TimeSpan.FromSeconds(gw.ConnectTimeoutSeconds),
|
||||||
|
DefaultCallTimeout = TimeSpan.FromSeconds(gw.DefaultCallTimeoutSeconds),
|
||||||
|
StreamTimeout = gw.StreamTimeoutSeconds > 0
|
||||||
|
? TimeSpan.FromSeconds(gw.StreamTimeoutSeconds)
|
||||||
|
: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Resolves <c>env:NAME</c>, <c>file:PATH</c>, and <c>dev:KEY</c> prefixes;
|
||||||
|
/// anything else is treated as a literal cleartext key with a startup warning.
|
||||||
|
/// Slim mirror of <c>GalaxyDriver.ResolveApiKey</c> — the runtime version lives
|
||||||
|
/// in a sibling project the Browser intentionally doesn't reference.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="secretRef">The secret reference string to resolve.</param>
|
||||||
|
private string ResolveApiKey(string secretRef)
|
||||||
|
{
|
||||||
|
ArgumentException.ThrowIfNullOrEmpty(secretRef);
|
||||||
|
|
||||||
|
if (secretRef.StartsWith("env:", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
var name = secretRef[4..];
|
||||||
|
var value = Environment.GetEnvironmentVariable(name);
|
||||||
|
return !string.IsNullOrEmpty(value)
|
||||||
|
? value
|
||||||
|
: throw new InvalidOperationException(
|
||||||
|
$"Galaxy.Gateway.ApiKeySecretRef='{secretRef}' resolves to env var '{name}', but it is unset.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (secretRef.StartsWith("file:", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
var path = secretRef[5..];
|
||||||
|
if (!File.Exists(path))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"Galaxy.Gateway.ApiKeySecretRef='{secretRef}' points at '{path}', which doesn't exist.");
|
||||||
|
}
|
||||||
|
var contents = File.ReadAllText(path).Trim();
|
||||||
|
return !string.IsNullOrEmpty(contents)
|
||||||
|
? contents
|
||||||
|
: throw new InvalidOperationException(
|
||||||
|
$"Galaxy.Gateway.ApiKeySecretRef='{secretRef}' file '{path}' is empty.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (secretRef.StartsWith("dev:", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
// Explicit dev opt-in — no warning, the operator deliberately chose a
|
||||||
|
// cleartext literal (dev box, parity rig).
|
||||||
|
return secretRef[4..];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Back-compat literal arm. An unprefixed string is treated as the literal
|
||||||
|
// API key — but emit a warning so an operator who accidentally committed a
|
||||||
|
// cleartext key into DriverConfig sees it when they open the address picker.
|
||||||
|
_logger.LogWarning(
|
||||||
|
"Galaxy.Gateway.ApiKeySecretRef is being treated as a literal cleartext API key. " +
|
||||||
|
"Prefer env:NAME, file:PATH, or the explicit dev:KEY prefix for dev rigs — " +
|
||||||
|
"a literal key in DriverConfig JSON is stored in cleartext in the central config DB.");
|
||||||
|
return secretRef;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user