using System.Data.Common; using System.Diagnostics; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts; namespace ZB.MOM.WW.OtOpcUa.Driver.Sql; /// /// The AdminUI "Test Connect" liveness check for the Sql driver: open a connection, run the /// dialect's SELECT 1 () under a bounded deadline, and report /// green with latency or red with a reason. Mirrors ModbusDriverProbe. /// Parses the SAME factory DTO with the SAME converter as /// (R2-11, 05/CONV-2 — the OpcUaClient parity rule), so a /// config the operator Test-Connects is byte-for-byte the config that Deploys: a numerically serialised /// provider parses here exactly as it does at the factory, and the same /// connectionStringRef resolves the same way. /// Never throws (the contract). Every failure — malformed JSON, a /// missing connectionStringRef, an unprovisioned connection string, a provider open failure, a /// timeout, a cancellation — becomes a red , so the AdminUI has nothing to /// catch. /// The resolved connection string never reaches the result message. A red result names the /// failure class, never the string — the same credential discipline the factory and the driver keep. /// public sealed class SqlDriverProbe : IDriverProbe { /// /// Selects the provider seam by . In production this constructs the real /// ; injects a test dialect (and factory) so the /// probe can run against the SQLite fixture with no SQL Server and no network. /// private readonly Func _dialectFor; /// /// Overrides the dialect's own when non-null — the injection point /// the SQLite tests use to hand in SqliteFactory while keeping the test dialect's /// catalog SQL. Null in production: the dialect's own factory is used. /// private readonly DbProviderFactory? _factoryOverride; /// Initializes a new that constructs the real SQL Server dialect. public SqlDriverProbe() : this(DefaultDialectFor, factoryOverride: null) { } private SqlDriverProbe(Func dialectFor, DbProviderFactory? factoryOverride) { _dialectFor = dialectFor; _factoryOverride = factoryOverride; } /// /// Test seam: a probe that always uses and opens connections from /// , so the SQLite fixture's create → open → SELECT 1 → close cycle /// runs unmodified. /// /// The provider factory the probe opens connections from. /// The dialect whose the probe runs. /// A probe wired to the supplied factory + dialect. /// A required argument is null. internal static SqlDriverProbe ForTest(DbProviderFactory factory, ISqlDialect dialect) { ArgumentNullException.ThrowIfNull(factory); ArgumentNullException.ThrowIfNull(dialect); return new SqlDriverProbe(_ => dialect, factory); } /// public string DriverType => SqlDriver.DriverTypeName; /// public async Task ProbeAsync(string configJson, TimeSpan timeout, CancellationToken ct) { // Parse + resolve first: a config that cannot even be read is a red result, not a connection attempt. SqlDriverConfigDto? dto; try { dto = System.Text.Json.JsonSerializer.Deserialize( configJson, SqlDriverFactoryExtensions.JsonOptions); } catch (Exception ex) when (ex is System.Text.Json.JsonException or ArgumentNullException) { return new DriverProbeResult(false, $"Config JSON is invalid: {ex.Message}", null); } if (dto is null) return new DriverProbeResult(false, "Config JSON deserialized to null.", null); if (string.IsNullOrWhiteSpace(dto.ConnectionStringRef)) return new DriverProbeResult(false, "Config has no connectionStringRef to resolve.", null); ISqlDialect dialect; string connectionString; try { dialect = _dialectFor(dto.Provider); connectionString = SqlConnectionStringResolver.Resolve(dto.ConnectionStringRef!); } catch (InvalidOperationException ex) { // Resolve throws with only the ref + env-var name — no secret in the message, so surfacing it is // safe and actionable (it names the environment variable the operator must set). return new DriverProbeResult( false, $"Could not run the SQL liveness check ({ex.GetType().Name}).", null); } var factory = _factoryOverride ?? dialect.Factory; return await RunLivenessAsync(factory, dialect, connectionString, timeout, ct).ConfigureAwait(false); } /// /// Opens one connection under a linked CTS bounded by , runs the dialect's /// liveness statement, and returns green with latency. Any failure becomes a red result whose message /// names the failure class — never the connection string (a provider exception can embed the /// data source, so its .Message is deliberately not surfaced). /// private static async Task RunLivenessAsync( DbProviderFactory factory, ISqlDialect dialect, string connectionString, TimeSpan timeout, CancellationToken ct) { var stopwatch = Stopwatch.StartNew(); using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct); if (timeout > TimeSpan.Zero) cts.CancelAfter(timeout); try { var connection = factory.CreateConnection() ?? throw new InvalidOperationException( $"the {dialect.Provider} provider factory returned no connection."); await using (connection.ConfigureAwait(false)) { connection.ConnectionString = connectionString; await connection.OpenAsync(cts.Token).ConfigureAwait(false); var command = connection.CreateCommand(); await using (command.ConfigureAwait(false)) { command.CommandText = dialect.LivenessSql; await command.ExecuteScalarAsync(cts.Token).ConfigureAwait(false); } } stopwatch.Stop(); return new DriverProbeResult(true, "SQL SELECT 1 OK", stopwatch.Elapsed); } catch (OperationCanceledException) when (ct.IsCancellationRequested) { return new DriverProbeResult(false, "Probe cancelled.", null); } catch (OperationCanceledException) { return new DriverProbeResult( false, $"Probe timed out after {timeout.TotalSeconds:F0}s.", null); } catch (Exception ex) { // The provider's own message can carry the data source (host/database), which is not a secret but // is more than the operator needs — and keeping the message to a fixed class is what guarantees no // credential-bearing connection string can ever slip through. Name the exception TYPE only. return new DriverProbeResult( false, $"Could not run the SQL liveness check ({ex.GetType().Name}).", null); } } /// Constructs the production dialect for a provider; v1 supports SQL Server only. private static ISqlDialect DefaultDialectFor(SqlProvider provider) => provider switch { SqlProvider.SqlServer => new SqlServerDialect(), _ => throw new InvalidOperationException( $"provider '{provider}' is not available in this build (only SqlServer is supported)."), }; }