feat(mtconnect): IDriverProbe one-shot /probe reachability (Task 14)
This commit is contained in:
@@ -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}";
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user