feat(sql): SqlDriverProbe SELECT-1 liveness check
The AdminUI "Test Connect" probe for the Sql driver: open a connection, run the dialect's LivenessSql (SELECT 1) under a linked-CTS deadline, return green with latency or red with a reason. Implements IDriverProbe; never throws (malformed JSON, missing connectionStringRef, unprovisioned connection string, a provider open failure, timeout, cancellation all become red results). Parses the SAME factory DTO with the SAME JsonStringEnumConverter as SqlDriverFactoryExtensions (R2-11 factory parity, mirroring ModbusDriverProbe) and resolves connectionStringRef the same way, so a config that Test-Connects is the config that Deploys. Credential hygiene: the resolved connection string never reaches the result message. A provider exception can embed the data source in its OWN message (the real SqlException shape), so the catch-all names the exception TYPE only, never ex.Message. The regression test's fake connection embeds the connection string in its thrown message specifically so surfacing ex.Message would leak it — verified load-bearing by breaking the guard and watching it go red. ForTest injects factory+dialect for the offline SQLite fixture path. Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
This commit is contained in:
@@ -0,0 +1,171 @@
|
||||
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;
|
||||
|
||||
/// <summary>
|
||||
/// The AdminUI "Test Connect" liveness check for the <c>Sql</c> driver: open a connection, run the
|
||||
/// dialect's <c>SELECT 1</c> (<see cref="ISqlDialect.LivenessSql"/>) under a bounded deadline, and report
|
||||
/// green with latency or red with a reason. Mirrors <c>ModbusDriverProbe</c>.
|
||||
/// <para><b>Parses the SAME factory DTO with the SAME converter as
|
||||
/// <see cref="SqlDriverFactoryExtensions"/></b> (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
|
||||
/// <c>provider</c> parses here exactly as it does at the factory, and the same
|
||||
/// <c>connectionStringRef</c> resolves the same way.</para>
|
||||
/// <para><b>Never throws</b> (the <see cref="IDriverProbe"/> contract). Every failure — malformed JSON, a
|
||||
/// missing <c>connectionStringRef</c>, an unprovisioned connection string, a provider open failure, a
|
||||
/// timeout, a cancellation — becomes a red <see cref="DriverProbeResult"/>, so the AdminUI has nothing to
|
||||
/// catch.</para>
|
||||
/// <para><b>The resolved connection string never reaches the result message.</b> A red result names the
|
||||
/// failure class, never the string — the same credential discipline the factory and the driver keep.</para>
|
||||
/// </summary>
|
||||
public sealed class SqlDriverProbe : IDriverProbe
|
||||
{
|
||||
/// <summary>
|
||||
/// Selects the provider seam by <see cref="SqlProvider"/>. In production this constructs the real
|
||||
/// <see cref="SqlServerDialect"/>; <see cref="ForTest"/> injects a test dialect (and factory) so the
|
||||
/// probe can run against the SQLite fixture with no SQL Server and no network.
|
||||
/// </summary>
|
||||
private readonly Func<SqlProvider, ISqlDialect> _dialectFor;
|
||||
|
||||
/// <summary>
|
||||
/// Overrides the dialect's own <see cref="ISqlDialect.Factory"/> when non-null — the injection point
|
||||
/// the SQLite tests use to hand in <c>SqliteFactory</c> while keeping the test dialect's
|
||||
/// catalog SQL. Null in production: the dialect's own factory is used.
|
||||
/// </summary>
|
||||
private readonly DbProviderFactory? _factoryOverride;
|
||||
|
||||
/// <summary>Initializes a new <see cref="SqlDriverProbe"/> that constructs the real SQL Server dialect.</summary>
|
||||
public SqlDriverProbe()
|
||||
: this(DefaultDialectFor, factoryOverride: null)
|
||||
{
|
||||
}
|
||||
|
||||
private SqlDriverProbe(Func<SqlProvider, ISqlDialect> dialectFor, DbProviderFactory? factoryOverride)
|
||||
{
|
||||
_dialectFor = dialectFor;
|
||||
_factoryOverride = factoryOverride;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Test seam: a probe that always uses <paramref name="dialect"/> and opens connections from
|
||||
/// <paramref name="factory"/>, so the SQLite fixture's create → open → <c>SELECT 1</c> → close cycle
|
||||
/// runs unmodified.
|
||||
/// </summary>
|
||||
/// <param name="factory">The provider factory the probe opens connections from.</param>
|
||||
/// <param name="dialect">The dialect whose <see cref="ISqlDialect.LivenessSql"/> the probe runs.</param>
|
||||
/// <returns>A probe wired to the supplied factory + dialect.</returns>
|
||||
/// <exception cref="ArgumentNullException">A required argument is null.</exception>
|
||||
internal static SqlDriverProbe ForTest(DbProviderFactory factory, ISqlDialect dialect)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(factory);
|
||||
ArgumentNullException.ThrowIfNull(dialect);
|
||||
return new SqlDriverProbe(_ => dialect, factory);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string DriverType => SqlDriver.DriverTypeName;
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<DriverProbeResult> 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<SqlDriverConfigDto>(
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Opens one connection under a linked CTS bounded by <paramref name="timeout"/>, runs the dialect's
|
||||
/// liveness statement, and returns green with latency. Any failure becomes a red result whose message
|
||||
/// names the failure class — <b>never the connection string</b> (a provider exception can embed the
|
||||
/// data source, so its <c>.Message</c> is deliberately not surfaced).
|
||||
/// </summary>
|
||||
private static async Task<DriverProbeResult> 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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Constructs the production dialect for a provider; v1 supports SQL Server only.</summary>
|
||||
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)."),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user