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;
///
/// One-shot MTConnect Agent reachability probe for the AdminUI's Test Connect button. Issues a
/// single GET {AgentUri}/probe under the supplied deadline and reports success/failure
/// with latency — it does not construct or initialize an instance.
///
///
///
/// Deliberately does NOT delegate to . That
/// method builds the full driver options document, including every authored
/// MTConnectTagDefinition (BuildTag can throw on a malformed tag entry that has
/// nothing to do with reachability). A probe only needs agentUri, 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
/// (MTConnectDriverFactoryExtensions), which is authored separately (Task 15).
///
///
/// Accepted gap: reachable ≠ deployable. Because only agentUri is read, a
/// config can show green on Test Connect and still fail to deploy — e.g. a non-positive
/// requestTimeoutMs or a malformed tag entry the factory's full parse would reject.
/// This is the direct consequence of the independent-parsing decision above, judged
/// acceptable because Test Connect's job is "is the endpoint there", not "will this exact
/// config deploy".
///
///
/// Deliberately DOES reuse and
/// for the actual round trip. Hand-rolling the HTTP
/// call would have to re-derive the exact behaviour the client already provides: per-call
/// bounding, a linked-CTS deadline, and — most importantly —
/// correct handling of an Agent's MTConnectError document served under HTTP 200 (so
/// EnsureSuccessStatusCode never fires). 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.
///
///
/// Never throws (the contract): every failure — unparseable
/// JSON, missing/malformed agentUri, DNS/TCP failure, a non-2xx response, a 200 body
/// that is not a valid MTConnectDevices document, and timeout — becomes
/// Ok = false with a message. Genuine caller cancellation is likewise converted rather
/// than left to propagate, matching ModbusDriverProbe's posture (its linked-CTS catch
/// does not distinguish the caller's token from its own deadline).
///
///
/// Non-positive timeout (arch-review 01/S-6). A 0 or negative
/// timeout does NOT mean "wait forever" — it is replaced with
/// (5s, matching 's own default) so an
/// operator-authorable field can never brick the probe UI.
///
///
/// Secrets discipline. AgentUri may carry userinfo credentials
/// (http://user:pass@host/). Every message this type constructs from the URI uses a
/// redacted display form () built ONLY from
/// ///
/// — never and never the raw config string — so a password can
/// never reach the AdminUI via the returned .
///
///
public sealed class MTConnectDriverProbe : IDriverProbe
{
/// Replaces a non-positive caller timeout — see the type remarks.
private static readonly TimeSpan FallbackTimeout = TimeSpan.FromSeconds(5);
///
/// Caps an excessive caller timeout. CancellationTokenSource.CancelAfter(TimeSpan)'s
/// legal range tops out near ~49.7 days ( ms minus one); an
/// uncapped pathological value (e.g. a caller passing TimeSpan.FromDays(1000)) throws
/// straight out of
/// CancelAfter, which would violate the "Never throws" contract just as surely as a
/// non-positive timeout meaning "wait forever" would (arch-review 01/S-6 — this is the same
/// defect class, the high end rather than the low end). 10 minutes is generous for a
/// reachability probe and leaves an enormous margin under the legal ceiling.
///
private static readonly TimeSpan MaxTimeout = TimeSpan.FromMinutes(10);
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNameCaseInsensitive = true,
UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip,
Converters = { new JsonStringEnumConverter() },
};
/// Test seam: routes the reused through a stub handler so response-shape cases need no socket.
private readonly HttpMessageHandler? _handler;
/// Creates the probe used in production (real sockets).
public MTConnectDriverProbe()
: this(handler: null)
{
}
/// Test seam constructor — see .
internal MTConnectDriverProbe(HttpMessageHandler? handler)
{
_handler = handler;
}
///
///
/// Sourced from the constant, not a literal — the probe is
/// looked up by AdminOperationsActor under this key, so a drift from the constant
/// silently disables the Test Connect button rather than failing anything at build time.
///
public string DriverType => DriverTypeNames.MTConnect;
/// Minimal probe-only config shape. Deliberately independent of the factory DTO — see the type remarks.
private sealed class MTConnectProbeConfigDto
{
public string? AgentUri { get; set; }
}
///
public async Task ProbeAsync(string configJson, TimeSpan timeout, CancellationToken ct)
{
MTConnectProbeConfigDto? dto;
try
{
dto = JsonSerializer.Deserialize(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" (low end), and a
// pathologically large one must never overrun CancelAfter's legal range (high end) — see
// MaxTimeout.
var effectiveTimeout = timeout > TimeSpan.Zero ? timeout : FallbackTimeout;
if (effectiveTimeout > MaxTimeout)
{
effectiveTimeout = MaxTimeout;
}
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)
{
// Last-resort catch for an exception type none of the above enumerate. Deliberately does
// NOT forward ex.Message: unlike the typed catches above (each individually audited for
// whether its message can carry the raw AgentUri/userinfo), an unenumerated type has no
// such audit, so ex.Message is an open door for a credential leak. safeUri + the
// exception's TYPE name is enough for an operator to act on without that risk.
return new(false, $"Probe against {safeUri} failed unexpectedly ({ex.GetType().Name}).", null);
}
}
///
/// Builds a display form of for use in returned messages, composed
/// ONLY from scheme/host/port/path — never — so a credentialed
/// AgentUri can never leak a password into the AdminUI.
///
private static string RedactedDisplay(Uri uri)
{
var portSegment = uri.IsDefaultPort ? string.Empty : $":{uri.Port}";
return $"{uri.Scheme}://{uri.Host}{portSegment}{uri.AbsolutePath}";
}
}