feat(mtconnect): IDriverProbe one-shot /probe reachability (Task 14)

This commit is contained in:
Joseph Doherty
2026-07-24 17:25:27 -04:00
parent a3a0cfe9bb
commit 1f1ae174d9
2 changed files with 435 additions and 0 deletions
@@ -0,0 +1,200 @@
using System.Diagnostics;
using System.Net.Http;
using System.Text.Json;
using System.Text.Json.Serialization;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect;
/// <summary>
/// One-shot MTConnect Agent reachability probe for the AdminUI's Test Connect button. Issues a
/// single <c>GET {AgentUri}/probe</c> under the supplied deadline and reports success/failure
/// with latency — it does not construct or initialize an <see cref="MTConnectDriver"/> instance.
/// </summary>
/// <remarks>
/// <para>
/// <b>Deliberately does NOT delegate to <see cref="MTConnectDriver.ParseOptions"/>.</b> That
/// method builds the full driver options document, including every authored
/// <c>MTConnectTagDefinition</c> (<c>BuildTag</c> can throw on a malformed tag entry that has
/// nothing to do with reachability). A probe only needs <c>agentUri</c>, so this type parses
/// its own minimal DTO — a config that is invalid for the driver (bad tag, bad data type) can
/// still be reachability-tested, matching the AdminUI's "does the endpoint answer" intent for
/// Test Connect. It also avoids a compile-time dependency on the factory DTO shape
/// (<c>MTConnectDriverFactoryExtensions</c>), which is authored separately (Task 15).
/// </para>
/// <para>
/// <b>Deliberately DOES reuse <see cref="MTConnectAgentClient"/> and
/// <see cref="MTConnectProbeParser"/> for the actual round trip.</b> Hand-rolling the HTTP
/// call would have to re-derive the exact behaviour the client already provides: per-call
/// <see cref="HttpClient.Timeout"/> bounding, a linked-CTS deadline, and — most importantly —
/// correct handling of an Agent's <c>MTConnectError</c> document served under HTTP 200 (so
/// <c>EnsureSuccessStatusCode</c> never fires). <see cref="MTConnectProbeParser"/> already
/// lifts the Agent's own error text into the thrown exception's message; re-parsing by hand
/// here would either duplicate that logic or under-validate (accepting any 200 response as
/// healthy, which is precisely the "empty-but-successful" defect class this codebase has hit
/// before, #485). The one-off cost of a full probe-document parse is negligible next to that
/// risk.
/// </para>
/// <para>
/// <b>Never throws</b> (the <see cref="IDriverProbe"/> contract): every failure — unparseable
/// JSON, missing/malformed <c>agentUri</c>, DNS/TCP failure, a non-2xx response, a 200 body
/// that is not a valid <c>MTConnectDevices</c> document, and timeout — becomes
/// <c>Ok = false</c> with a message. Genuine caller cancellation is likewise converted rather
/// than left to propagate, matching <c>ModbusDriverProbe</c>'s posture (its linked-CTS catch
/// does not distinguish the caller's token from its own deadline).
/// </para>
/// <para>
/// <b>Non-positive <c>timeout</c> (arch-review 01/S-6).</b> A <c>0</c> or negative
/// timeout does NOT mean "wait forever" — it is replaced with <see cref="FallbackTimeout"/>
/// (5s, matching <see cref="MTConnectDriverOptions.RequestTimeoutMs"/>'s own default) so an
/// operator-authorable field can never brick the probe UI.
/// </para>
/// <para>
/// <b>Secrets discipline.</b> <c>AgentUri</c> may carry userinfo credentials
/// (<c>http://user:pass@host/</c>). Every message this type constructs from the URI uses a
/// redacted display form (<see cref="RedactedDisplay"/>) built ONLY from
/// <see cref="Uri.Scheme"/>/<see cref="Uri.Host"/>/<see cref="Uri.Port"/>/<see cref="Uri.AbsolutePath"/>
/// — never <see cref="Uri.UserInfo"/> and never the raw config string — so a password can
/// never reach the AdminUI via the returned <see cref="DriverProbeResult.Message"/>.
/// </para>
/// </remarks>
public sealed class MTConnectDriverProbe : IDriverProbe
{
/// <summary>Replaces a non-positive caller <c>timeout</c> — see the type remarks.</summary>
private static readonly TimeSpan FallbackTimeout = TimeSpan.FromSeconds(5);
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNameCaseInsensitive = true,
UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip,
Converters = { new JsonStringEnumConverter() },
};
/// <summary>Test seam: routes the reused <see cref="MTConnectAgentClient"/> through a stub handler so response-shape cases need no socket.</summary>
private readonly HttpMessageHandler? _handler;
/// <summary>Creates the probe used in production (real sockets).</summary>
public MTConnectDriverProbe()
: this(handler: null)
{
}
/// <summary>Test seam constructor — see <see cref="_handler"/>.</summary>
internal MTConnectDriverProbe(HttpMessageHandler? handler)
{
_handler = handler;
}
/// <inheritdoc />
public string DriverType => "MTConnect";
/// <summary>Minimal probe-only config shape. Deliberately independent of the factory DTO — see the type remarks.</summary>
private sealed class MTConnectProbeConfigDto
{
public string? AgentUri { get; set; }
}
/// <inheritdoc />
public async Task<DriverProbeResult> ProbeAsync(string configJson, TimeSpan timeout, CancellationToken ct)
{
MTConnectProbeConfigDto? dto;
try
{
dto = JsonSerializer.Deserialize<MTConnectProbeConfigDto>(configJson, JsonOptions);
}
catch (Exception ex)
{
return new(false, $"Config JSON is invalid: {ex.Message}", null);
}
if (dto is null)
{
return new(false, "Config JSON deserialized to null.", null);
}
var agentUri = dto.AgentUri?.Trim();
if (string.IsNullOrWhiteSpace(agentUri))
{
return new(false, "Config has no agentUri to probe.", null);
}
if (!Uri.TryCreate(agentUri, UriKind.Absolute, out var parsed) ||
(parsed.Scheme != Uri.UriSchemeHttp && parsed.Scheme != Uri.UriSchemeHttps))
{
return new(false, "Config's agentUri is not an absolute http(s) URI.", null);
}
var safeUri = RedactedDisplay(parsed);
// arch-review 01/S-6: a non-positive timeout must never mean "wait forever".
var effectiveTimeout = timeout > TimeSpan.Zero ? timeout : FallbackTimeout;
var requestTimeoutMs = Math.Max(1, (int)Math.Ceiling(effectiveTimeout.TotalMilliseconds));
var sw = Stopwatch.StartNew();
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
cts.CancelAfter(effectiveTimeout);
var options = new MTConnectDriverOptions
{
AgentUri = agentUri,
RequestTimeoutMs = requestTimeoutMs,
};
try
{
using var client = new MTConnectAgentClient(options, _handler);
_ = await client.ProbeAsync(cts.Token).ConfigureAwait(false);
sw.Stop();
return new(true, $"MTConnect /probe OK ({safeUri})", sw.Elapsed);
}
catch (OperationCanceledException)
{
// Covers both the caller's token and our own CancelAfter deadline firing — see the type
// remarks on why cancellation is never allowed to propagate.
return new(false, $"Probe timed out after {effectiveTimeout.TotalSeconds:F0}s.", null);
}
catch (TimeoutException)
{
// MTConnectAgentClient's own request-deadline signal.
return new(false, $"Probe timed out after {effectiveTimeout.TotalSeconds:F0}s.", null);
}
catch (HttpRequestException ex)
{
// Connection failure or non-2xx status. HttpRequestException messages describe the
// socket endpoint or status code, never the request URI/userinfo, so ex.Message is safe.
return new(false, $"Request to {safeUri} failed: {ex.Message}", null);
}
catch (InvalidDataException ex)
{
// MTConnectProbeParser: not well-formed XML, not an MTConnectDevices document (including
// an MTConnectError-under-200, whose own errorCode/text ex.Message already carries), or
// a document with no devices. Never includes the request URI.
return new(false, $"Agent at {safeUri} did not return a valid MTConnect device model: {ex.Message}", null);
}
catch (ArgumentException)
{
// Defensive: MTConnectAgentClient's own AgentUri validation, reached only if this
// method's own Uri.TryCreate above passed but the client's stricter check disagreed.
// Never forward the exception's message verbatim — it can echo the raw (possibly
// credentialed) config string.
return new(false, "Config's agentUri was rejected by the MTConnect client as invalid.", null);
}
catch (Exception ex)
{
return new(false, ex.Message, null);
}
}
/// <summary>
/// Builds a display form of <paramref name="uri"/> for use in returned messages, composed
/// ONLY from scheme/host/port/path — never <see cref="Uri.UserInfo"/> — so a credentialed
/// <c>AgentUri</c> can never leak a password into the AdminUI.
/// </summary>
private static string RedactedDisplay(Uri uri)
{
var portSegment = uri.IsDefaultPort ? string.Empty : $":{uri.Port}";
return $"{uri.Scheme}://{uri.Host}{portSegment}{uri.AbsolutePath}";
}
}