using System.Text.Json; using System.Text.Json.Serialization; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using ZB.MOM.WW.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; /// /// Opens transient gateway connections for the AdminUI address picker. Mirrors the /// runtime GalaxyDriver.BuildClientOptions pattern so the gateway sees the /// same option shape, but tweaks /// resolution + the gateway-side client identity so browse sessions are /// distinguishable from the runtime driver's live MX session. /// public sealed class GalaxyDriverBrowser : IDriverBrowser { /// /// Identifier used in gateway-side logs / metrics for AdminUI browse sessions. /// Distinct from any runtime driver's MxAccess.ClientName so an operator /// can tell the two apart when triaging. /// internal const string BrowseClientIdentity = "OtOpcUa-AdminUI-Browse"; /// Hard cap on the time we'll wait for the initial gateway handshake. private static readonly TimeSpan ConnectBudget = TimeSpan.FromSeconds(30); private static readonly JsonSerializerOptions JsonOpts = new() { UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip, PropertyNameCaseInsensitive = true, }; private readonly ILogger _logger; /// Creates a new browser. Logger defaults to . /// Optional logger; null is allowed for unit-test construction. public GalaxyDriverBrowser(ILogger? logger = null) { _logger = logger ?? NullLogger.Instance; } /// // Hardcoded literal: this project references Driver.Galaxy.Contracts, not Driver.Galaxy, // so GalaxyDriverFactoryExtensions.DriverTypeName isn't available here. public string DriverType => "GalaxyMxGateway"; // Deserializes a GalaxyDriverOptions blob, opens a transient GalaxyRepositoryClient // against the configured gateway endpoint, and returns a browse session over it. The // session owns the client and disposes it on IBrowseSession.DisposeAsync. Throws // InvalidOperationException when the JSON deserialises to null, when Gateway.Endpoint // is empty, or when MxAccess.ClientName is empty. /// public async Task OpenAsync(string configJson, CancellationToken cancellationToken) { var opts = JsonSerializer.Deserialize(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; } } /// /// Build the gateway client options from the form's Gateway section. Mirrors the /// runtime driver's GalaxyDriver.BuildClientOptions field-for-field so the /// gateway sees an identical option shape. The API-key reference is resolved via /// the shared in Driver.Galaxy.Contracts /// (the same resolver the runtime driver uses), so browse and runtime stay in lock-step. /// private MxGatewayClientOptions BuildClientOptions(GalaxyGatewayOptions gw) => new() { Endpoint = new Uri(gw.Endpoint, UriKind.Absolute), ApiKey = GalaxySecretRef.ResolveApiKey(gw.ApiKeySecretRef, _logger), 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, }; }